Ctrl K
ComunidadGuías…

hack/release-changelog-contract.bats

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bats
# Guards the contract that makes a release publish WITH its changelog.
#
# The release body is no longer written by hand or synced after the fact: it is
# docs/changelogs/vX.Y.Z.md, generated by the reusable changelog-generate.yaml,
# landed on a staging branch, carried through the promote PR, and read back by
# pull-requests-release.yaml finalize at publication. That chain now spans FIVE
# workflow files and a docs contract:
#
#   * changelog-generate.yaml — reusable (workflow_call) generation core: detached
#     rc-tag checkout, read-only token for the AI step, validator gate, artifact.
#   * changelog-rc.yaml — the rc-time button: calls the reusable core, then commits
#     the result onto the rc staging branch release-X.Y.Z-rc.N.
#   * promote-rc.yaml — calls the reusable core at promote time (a thin `uses:`),
#     and its open-pr job commits the changelog onto release-X.Y.Z before the PR.
#   * pull-requests-release.yaml — finalize reads the file as the release body.
#   * tags.yaml — the after-the-fact backstop for the degraded paths.
#
# Every link is a plain string — a job rename, a moved `if:`, or a reworded prompt
# breaks it silently, and the failure only ever shows up as a published release
# with empty notes. That is exactly the class of bug nobody notices until it is
# public and irreversible.
#
# The tests below pin the links that are cheap to assert and expensive to lose:
#
#   1. open-pr survives a failed changelog job (the non-blocking guarantee).
#   2. The AI step is the ONLY thing allowed to fail silently.
#   3. finalize reads the changelog file as the release body.
#   3b. finalize and update-releasenotes share the 100000-byte body limit, and
#      the sync fires on the release notes too.
#   4. finalize refuses whitespace-only content rather than blanking the notes.
#   5. The generator is pointed at a checkout state docs/agents/changelog.md
#      actually permits.
#   6. The tags.yaml backstop ports an existing changelog instead of regenerating.
#   7. promote preserves a changelog across the staging-branch force-push.
#   8. The finalize trigger has no paths filter that a docs-only promote PR would
#      trip over.
#   9-11. The validator accepts every changelog of the current era (both header
#      conventions, including a 19-line patch release) and rejects unusable output.
#   11b. It also rejects an entry cut off with an ellipsis and a link to
#      cozystack.io/docs/next/, and nothing that merely resembles either.
#   12. The workflow calls the validator script rather than reimplementing it.
#   13. The agent instructions, the release checks they send the agent to and the
#      validator are overlaid from the dispatch ref, because the rc tag's copies
#      predate this workflow.
#   14. open-pr survives an artifact transfer failure and keys off the file on disk.
#   15. A pre-existing changelog is validated, not merely non-empty.
#   16. promote-rc parses the rc tag ONCE in a cheap `parse` job that rejects
#      whitespace (not trims), and its changelog job is a thin `uses:` of the
#      reusable core fed that validated tag — so promote and the changelog job
#      cannot diverge on validity (which left a PR with no changelog).
#   17. The reusable core exposes `present`, and open-pr still keys off it.
#   18. The reusable core delegates source selection to select-changelog-source.sh
#      (validate-then-fall-through, behaviour-tested), rc tag tree over rc staging
#      branch over generate — an invalid source never suppresses a valid one.
#   19. changelog-rc.yaml validates the rc tag via the shared strict parser
#      (hack/parse-rc-tag.sh — rejects whitespace, not strips it).
#   20. changelog-rc.yaml calls the same reusable core (no forked second copy).
#   21. changelog-rc.yaml verifies the rc STAGING branch UP FRONT (in parse, which
#      generate depends on), so a missing branch fails before generation runs.
#   22. changelog-rc.yaml commits to the rc STAGING branch release-X.Y.Z-rc.N
#      idempotently (compare-before-push) and validated, hard-failing on empty.
#   23. The artifact name is identical across producer and both consumers.
#
# WHAT THIS DOES NOT CHECK, so a green tick is not mistaken for a working release:
#
#   * That the changelog is CORRECT, or that the AI produces anything at all.
#     Generation quality is unobservable from here and is what review is for.
#   * That GitHub's `needs` / `if:` / reusable-workflow semantics behave as
#     assumed. These tests pin what the YAML SAYS; whether a skipped upstream job
#     really propagates, or a called workflow really sees the caller's ref, is
#     GitHub's contract, verifiable only by dispatching a real run.
#   * Anything in cozystack/website. The docs job deliberately still runs at tag
#     push (where RELEASE_TAG resolves to a tag that exists) — moving it earlier
#     needs a change in that repo first, and nothing here would catch its absence.
#   * Whether the release body ACTUALLY lands. That is one API call at publish
#     time, on a path no unit test reaches.
#
# Test-level EXIT traps replace Bats' own handler and hide failing TAP results.
# Cleanup follows aborting assertions; see docs/agents/e2e-testing.md.

load test_helper

REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
PROMOTE="$REPO_ROOT/.github/workflows/promote-rc.yaml"
GENERATE="$REPO_ROOT/.github/workflows/changelog-generate.yaml"
RC_WRAPPER="$REPO_ROOT/.github/workflows/changelog-rc.yaml"
FINALIZE="$REPO_ROOT/.github/workflows/pull-requests-release.yaml"
TAGS="$REPO_ROOT/.github/workflows/tags.yaml"
SYNC="$REPO_ROOT/.github/workflows/update-releasenotes.yaml"
CHANGELOG_DOC="$REPO_ROOT/docs/agents/changelog.md"

# Body of a single top-level job block in a workflow file, from `  <name>:` up to
# the next top-level job. Keyed on two-space indentation, which is the layout
# every workflow in this repo uses.
job_block() {
  awk -v job="  $1:" '
    $0 == job { inside = 1; next }
    /^  [a-z0-9_-]+:$/ { inside = 0 }
    inside' "$2"
}

# Executable/structural lines of a block: strip YAML- and shell-comment lines
# (leading whitespace then '#') so a pin cannot be satisfied by a commented-out
# line. Reads the block from stdin.
code_lines() { grep -vE '^[[:space:]]*#'; }

@test "all release workflow files exist" {
  for f in "$PROMOTE" "$GENERATE" "$RC_WRAPPER" "$FINALIZE" "$TAGS" "$CHANGELOG_DOC"; do
    [ -f "$f" ] || { echo "missing $f" >&2; exit 1; }
  done
}

# 1. The non-blocking guarantee. `needs: [promote, changelog]` alone makes GitHub
#    SKIP open-pr whenever changelog fails, which would leave the staging branch
#    pushed and the release drafted but no promote PR at all — strictly worse than
#    the old flow, where the PR was opened inside promote and nothing about
#    changelog generation could block it. A Copilot or npm outage must degrade to
#    "PR opens without a changelog", never to "no PR".
@test "open-pr opens the promote PR even when the changelog job fails" {
  block="$(job_block open-pr "$PROMOTE")"
  [ -n "$block" ] || { echo "Could not locate the open-pr job in $PROMOTE" >&2; exit 1; }

  # Tolerate extra needs (e.g. website-docs) appended to the list — the load-
  # bearing part is that open-pr still depends on both promote and changelog.
  printf '%s\n' "$block" | grep -qE 'needs: \[promote, changelog(,|\])' || {
    echo "open-pr no longer declares 'needs: [promote, changelog...]'." >&2
    exit 1
  }

  # Must not be gated on the changelog job's result.
  printf '%s\n' "$block" | grep -qE '^\s*if:.*!cancelled\(\)' || {
    echo "open-pr has no '!cancelled()' job-level if:. Without one, a failed" >&2
    echo "changelog job SKIPS open-pr and no promote PR is ever opened." >&2
    exit 1
  }
  printf '%s\n' "$block" | grep -qE "^\s*if:.*needs\.promote\.result == 'success'" || {
    echo "open-pr's if: no longer gates on needs.promote.result == 'success'." >&2
    exit 1
  }
  printf '%s\n' "$block" | grep -qE '^\s*if:.*needs\.changelog\.result' && {
    echo "open-pr's job-level if: references needs.changelog.result. Gating the" >&2
    echo "PR on the changelog job re-introduces the wedge this if: exists to prevent." >&2
    exit 1
  }
  return 0
}

# 2. continue-on-error is a blast radius, not a convenience. It belongs on the AI
#    invocation and nowhere else: on a verification or commit step it would let a
#    broken changelog through as if it were fine. The generation steps now live in
#    the reusable changelog-generate.yaml, so that is where this is pinned.
@test "only the AI generation step is allowed to fail silently" {
  block="$(job_block changelog "$GENERATE")"
  [ -n "$block" ] || { echo "Could not locate the changelog job in $GENERATE" >&2; exit 1; }

  # Only one step may legitimately fail without failing the job:
  #
  #   Generate changelog using AI            — the whole point of non-blocking.
  #
  # Anywhere else it would let an unvalidated or missing changelog through as if
  # it were fine — in particular on Verify, which is the gate that decides whether
  # a fragment becomes the release body.
  allowed="Generate changelog using AI"

  offenders="$(printf '%s\n' "$block" | awk -v allowed="$allowed" '
    /^      - name: / {
      name = $0
      sub(/^      - name: /, "", name)
      ok = (name ~ "^(" allowed ")$")
      next
    }
    /^        continue-on-error: true$/ { if (!ok) print name }
  ')"

  [ -z "$offenders" ] || {
    echo "These changelog-job steps carry continue-on-error but must not:" >&2
    printf '  %s\n' "$offenders" >&2
    echo "Allowing them to fail silently would let an unvalidated or absent changelog" >&2
    echo "be advertised as valid and published as the release body." >&2
    exit 1
  }

  # And the AI step must actually still have it, or generation is blocking again.
  ai_step="$(printf '%s\n' "$block" | awk '
    /^      - name: Generate changelog using AI$/ { inside = 1; next }
    /^      - name: / { inside = 0 }
    inside')"
  [ -n "$ai_step" ] || { echo "Could not locate the 'Generate changelog using AI' step" >&2; exit 1; }
  printf '%s\n' "$ai_step" | grep -q 'continue-on-error: true' || {
    echo "'Generate changelog using AI' no longer carries continue-on-error —" >&2
    echo "a Copilot outage would now block the release." >&2
    exit 1
  }
}

# 3. The load-bearing read. If finalize stops reading the file, releases publish
#    with whatever the draft happened to carry ("Promoted from vX.Y.Z-rc.N.") and
#    the whole point of generating the changelog before publication is lost.
@test "finalize uses the changelog file as the published release body" {
  grep -q 'docs/changelogs/\${tag}\.md' "$FINALIZE" || {
    echo "finalize no longer references docs/changelogs/\${tag}.md." >&2
    echo "The release body would fall back to the draft's placeholder text." >&2
    exit 1
  }
  # The read must actually reach updateRelease.
  grep -qE '\.\.\.\(body \? \{ body \} : \{\}\)' "$FINALIZE" || {
    echo "finalize's updateRelease call no longer conditionally spreads { body }." >&2
    exit 1
  }
}

# 3b. finalize publishes a link in place of a body over the limit, because the
#     tags exist by then; the sync leaves such a body alone. Both read the same
#     constant, and what each does with it is executed in
#     hack/release-changelog-behaviour.bats. The sync must also fire when only
#     the release notes change, or editing them on main reaches no release.
@test "finalize and update-releasenotes share the 100000-byte body limit, and the sync fires on the release notes" {
  for f in "$FINALIZE" "$SYNC"; do
    grep -qE '^[[:space:]]*const MAX_BODY_BYTES = 100000;' "$f" || {
      echo "$(basename "$f") no longer declares const MAX_BODY_BYTES = 100000 on a code line." >&2
      exit 1
    }
    if grep -q 'release-body\.sh' "$f"; then
      echo "$(basename "$f") mentions hack/release-body.sh, which no longer exists." >&2
      false
    fi
  done
  paths="$(yq '.on.push.paths[]' "$SYNC")"
  for p in 'docs/changelogs/v*.md' 'docs/releasenotes/v*.md'; do
    printf '%s\n' "$paths" | grep -qxF "$p" || {
      echo "update-releasenotes no longer fires on pushes touching $p." >&2
      exit 1
    }
  done
}

# 4. A whitespace-only file is worse than no file: it would blank the release
#    notes more thoroughly than publishing the draft body would. The generation
#    core validates structurally, but a changelog can also arrive by hand-commit,
#    so finalize keeps its own last-line-of-defence check.
@test "finalize refuses an empty or whitespace-only changelog as the body" {
  grep -qE 'raw\.trim\(\)\.length === 0' "$FINALIZE" || {
    echo "finalize no longer guards against a whitespace-only changelog." >&2
    echo "Such a file would publish as blank release notes." >&2
    exit 1
  }
}

# 5. docs/agents/changelog.md section 2 is a hard gate: it enumerates the valid
#    working-tree states and tells the agent to STOP otherwise. The generation
#    core checks out the rc tag, so that state must be one the doc names —
#    otherwise a COMPLIANT agent refuses to generate anything and the normal path
#    produces no changelog at all, silently, forever.
@test "the generation checkout state is one docs/agents/changelog.md permits" {
  block="$(job_block changelog "$GENERATE")"
  printf '%s\n' "$block" | grep -qE 'ref: \$\{\{ steps\.parse\.outputs\.rc_tag \}\}' || {
    echo "The changelog job no longer checks out the rc TAG." >&2
    echo "A staging-branch checkout is a state changelog.md section 2 rejects." >&2
    exit 1
  }
  grep -qi 'release-candidate.* tag being promoted' "$CHANGELOG_DOC" || {
    echo "docs/agents/changelog.md section 2 no longer documents the promotion" >&2
    echo "configuration (detached at the rc tag). A compliant agent would stop." >&2
    exit 1
  }
  # The checkout must never consume raw dispatch/caller input.
  printf '%s\n' "$block" | grep -qE 'ref: \$\{\{ inputs\.rc_tag \}\}' && {
    echo "The changelog job checks out raw inputs.rc_tag instead of the validated" >&2
    echo "parse output — untrusted input reaching actions/checkout's ref." >&2
    exit 1
  }
  return 0
}

# 6. A promote PR targets release-X.Y for EVERY release since the rc freeze, so
#    the changelog never reaches main on its own and the backstop fires as the
#    normal path rather than the patch-release corner case it was written for. It
#    must PORT the reviewed file from the tag, not regenerate: a second AI pass
#    would spend quota and then, once its PR merged, update-releasenotes.yaml
#    would overwrite already-published release notes with text nobody approved.
@test "the tags.yaml backstop ports an existing changelog instead of regenerating" {
  block="$(job_block generate-changelog "$TAGS")"
  [ -n "$block" ] || { echo "Could not locate generate-changelog in $TAGS" >&2; exit 1; }

  printf '%s\n' "$block" | grep -q 'at_tag=true' || {
    echo "The backstop no longer detects a changelog present at the tag commit." >&2
    exit 1
  }

  # Every AI step must be gated on the file being absent at the tag.
  for step in 'Setup Node.js' 'Install GitHub Copilot CLI' 'Generate changelog using AI'; do
    step_block="$(printf '%s\n' "$block" | awk -v s="      - name: $step" '
      $0 == s { inside = 1; next }
      /^      - name: / { inside = 0 }
      inside')"
    [ -n "$step_block" ] || { echo "Could not locate backstop step '$step'" >&2; exit 1; }
    printf '%s\n' "$step_block" | grep -qE "at_tag == 'false'" || {
      echo "Backstop step '$step' is not gated on at_tag == 'false'." >&2
      echo "It would regenerate a changelog that already exists at the tag, and the" >&2
      echo "resulting PR would overwrite published release notes." >&2
      exit 1
    }
  done
}

# 7. The staging branch is rebuilt from the rc tree and force-pushed on every
#    re-dispatch. A changelog committed there by hand (the documented recovery
#    when generation fails) or by an earlier dispatch must survive that, or a
#    retry silently replaces human-edited release notes.
@test "promote preserves an existing changelog across the staging-branch rebuild" {
  block="$(job_block promote "$PROMOTE")"
  [ -n "$block" ] || { echo "Could not locate the promote job in $PROMOTE" >&2; exit 1; }

  printf '%s\n' "$block" | grep -q 'changelog-preserve.sh' || {
    echo "The Prepare stable branch step no longer calls changelog-preserve.sh." >&2
    echo "A re-dispatch would force-push away a hand-written changelog." >&2
    exit 1
  }
  # It must run BEFORE the branch is reset, or there is nothing left to rescue.
  pres_line="$(printf '%s\n' "$block" | grep -n 'changelog-preserve.sh' | head -1 | cut -d: -f1)"
  reset_line="$(printf '%s\n' "$block" | grep -n 'git checkout -B' | head -1 | cut -d: -f1)"
  [ -n "$pres_line" ] && [ -n "$reset_line" ] && [ "$pres_line" -lt "$reset_line" ] || {
    echo "changelog-preserve.sh does not run before 'git checkout -B'; by then the" >&2
    echo "working tree has already been reset and there is nothing to preserve." >&2
    exit 1
  }
  # The script itself is behaviour-tested against real git remotes in
  # hack/release-changelog-behaviour.bats; this only pins that it is still wired in.
  [ -x "$REPO_ROOT/hack/changelog-preserve.sh" ] || {
    echo "hack/changelog-preserve.sh is missing or not executable." >&2; exit 1; }

  # And open-pr must not clobber what promote preserved.
  open_block="$(job_block open-pr "$PROMOTE")"
  printf '%s\n' "$open_block" | grep -qE "steps\.existing\.outputs\.present == 'false'" || {
    echo "open-pr no longer checks for an existing changelog before downloading the" >&2
    echo "generated artifact — it would overwrite preserved human edits." >&2
    exit 1
  }
}

# 8. The promote PR now carries docs/changelogs/vX.Y.Z.md, and Prepare stable
#    branch tolerates producing no tag-string changes ("digests already stable").
#    Together those can yield a docs-only promote PR. A `paths-ignore: docs/**`
#    on the finalize trigger would drop it: no finalize run, so no tag, no
#    release, and no error raised anywhere.
@test "the finalize trigger has no paths filter that a docs-only promote PR would trip" {
  trigger="$(awk '/^on:/ { inside = 1; next } /^[a-z]/ { inside = 0 } inside' "$FINALIZE")"
  [ -n "$trigger" ] || { echo "Could not locate the on: block in $FINALIZE" >&2; exit 1; }

  printf '%s\n' "$trigger" | grep -qE '^\s*(paths|paths-ignore):' && {
    echo "The finalize trigger declares a paths filter. A promote PR whose only" >&2
    echo "change is the changelog would be filtered out, and the release would" >&2
    echo "never be tagged or published — silently. Filtering belongs in the job's" >&2
    echo "if: gate (merged + release label + bot author), not the trigger." >&2
    exit 1
  }
  return 0
}

# 9. The validator is a script, not inline YAML, precisely so these tests can run
#    it. An earlier inline version asserted a `# Cozystack vX.Y.Z` header and a
#    20-line floor — both of which reject every PATCH changelog in the tree, and
#    the suite at the time was fully green while that shipped. Feed it the real
#    files and let them be the specification.
#
#    Scope note: only v1.3.0+ is asserted. Changelogs before that predate the
#    current documented process — v0.31 through v1.2.x carry no H1 at all — and
#    the validator never sees them, since it only ever runs on freshly generated
#    output. Holding it to a form the process no longer produces would be testing
#    history, not behaviour.
@test "the changelog validator accepts every changelog of the current era" {
  [ -x "$REPO_ROOT/hack/validate-changelog.sh" ] || {
    echo "hack/validate-changelog.sh missing or not executable" >&2; exit 1; }

  checked=0
  rejected=""
  for f in "$REPO_ROOT"/docs/changelogs/v1.[3-9]*.md; do
    [ -f "$f" ] || continue
    base="$(basename "$f" .md)"
    version="${base#v}"
    checked=$((checked + 1))
    if ! "$REPO_ROOT/hack/validate-changelog.sh" "$f" "$version" >/dev/null 2>&1; then
      reason="$("$REPO_ROOT/hack/validate-changelog.sh" "$f" "$version" 2>&1 || true)"
      rejected="$rejected
  $base: $reason"
    fi
  done

  # Anti-vacuum: a glob that matches nothing would pass silently.
  [ "$checked" -ge 10 ] || {
    echo "Only $checked changelog(s) checked; expected at least 10. The glob broke" >&2
    echo "and this test is verifying nothing." >&2
    exit 1
  }

  [ -z "$rejected" ] || {
    echo "The validator rejects shipped changelogs — it would discard good AI output" >&2
    echo "and mislabel the promote PR as a generation failure:$rejected" >&2
    exit 1
  }
}

# 10. Both header conventions are live: minors use `# Cozystack vX.Y.Z`, patches
#     use `# vX.Y.Z (date)`. Pin one of each so a future tightening cannot drop
#     the patch form, which is the more common release by roughly 5:1.
@test "the validator accepts both the minor and patch header conventions" {
  minor="$REPO_ROOT/docs/changelogs/v1.5.0.md"   # '# Cozystack v1.5.0'
  patch="$REPO_ROOT/docs/changelogs/v1.5.1.md"   # '# v1.5.1 (2026-06-23)', 19 lines

  for pair in "$minor:1.5.0" "$patch:1.5.1"; do
    f="${pair%:*}"; v="${pair##*:}"
    [ -f "$f" ] || { echo "fixture $f is gone; pick another of the same shape" >&2; exit 1; }
    "$REPO_ROOT/hack/validate-changelog.sh" "$f" "$v" >/dev/null 2>&1 || {
      echo "Validator rejected $(basename "$f") — a legitimate, shipped changelog." >&2
      "$REPO_ROOT/hack/validate-changelog.sh" "$f" "$v" 2>&1 >/dev/null | sed 's/^/    /' >&2
      exit 1
    }
  done

  # v1.5.1 is 19 lines. Any line-count floor at 20 or above silently rejects it.
  lines="$(wc -l < "$patch" | tr -d ' ')"
  [ "$lines" -lt 25 ] || {
    echo "v1.5.1 is now $lines lines — it was the short-patch fixture precisely" >&2
    echo "because it is 19. Pick a genuinely short changelog instead." >&2
    exit 1
  }
}

# 11. The other half of the contract: what must be REJECTED. Without these the
#     validator could degenerate to `exit 0` and tests 9 and 10 would still pass.
@test "the changelog validator rejects unusable output" {
  tmp="$(mktemp -d)"

  printf '   \n\n  \n' > "$tmp/whitespace.md"
  if "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/whitespace.md" 1.6.0 >/dev/null 2>&1; then
    echo "Validator accepted a whitespace-only file — it would publish as blank release notes." >&2
    exit 1
  fi

  : > "$tmp/empty.md"
  if "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/empty.md" 1.6.0 >/dev/null 2>&1; then
    echo "Validator accepted an empty file." >&2; exit 1; fi

  if "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/absent.md" 1.6.0 >/dev/null 2>&1; then
    echo "Validator accepted a nonexistent file." >&2; exit 1; fi

  # Truncated mid-stream: head intact, tail (the compare link) lost. This is the
  # realistic AI failure — a timeout or quota cutoff partway through the write.
  head -8 "$REPO_ROOT/docs/changelogs/v1.5.1.md" > "$tmp/truncated.md"
  if "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/truncated.md" 1.5.1 >/dev/null 2>&1; then
    echo "Validator accepted a truncated changelog — a fragment would be published" >&2
    echo "verbatim as the release body." >&2
    exit 1
  fi

  # Right shape, wrong version: a stale file left by a previous promotion, or a
  # changelog generated against the rc tag instead of the stable one.
  if "$REPO_ROOT/hack/validate-changelog.sh" "$REPO_ROOT/docs/changelogs/v1.5.1.md" 1.6.0 >/dev/null 2>&1; then
    echo "Validator accepted v1.5.1's changelog as v1.6.0's release body." >&2
    exit 1
  fi
  rm -rf "$tmp"
}

# 11b. Two content defects that reached a generated release body: an entry cut
#      off with an ellipsis before its attribution, which loses the operator
#      action PR authors put at the end of a release note, and a link to
#      cozystack.io/docs/next/, which the live site answers with 404. Each is
#      planted in a shipped changelog, so a rejection cannot come from broken
#      structure instead, and the reason must name the line and fit on one
#      line, since the workflows quote it into a ::warning:: or ::error::
#      annotation, which ends at the first newline. The near misses at the end
#      are shapes shipped changelogs carry.
@test "the changelog validator rejects truncated entries and docs/next links, and only those" {
  base="$REPO_ROOT/docs/changelogs/v1.5.1.md"
  [ -f "$base" ] || { echo "fixture $base is gone; pick another shipped changelog" >&2; exit 1; }
  tmp="$(mktemp -d)"
  line=$(( $(wc -l < "$base") + 1 ))

  for bad in \
    'ellipsis|* **fix(app): restore the bucket**: The restore now carries the bucket, and the… ([**@someone**](https://github.com/someone) in #1)' \
    'ellipsis|* **fix(app): restore the bucket**: The restore now carries the bucket, and the... ([**@someone**](https://github.com/someone) in #1)' \
    'docs/next|Learn more in the [documentation](https://cozystack.io/docs/next/).' \
    'docs/next|Learn more in the [documentation](https://cozystack.io/docs/next).'
  do
    want="${bad%%|*}"; text="${bad#*|}"
    { cat "$base"; printf '%s\n' "$text"; } > "$tmp/bad.md"
    if "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/bad.md" 1.5.1 >/dev/null 2>&1; then
      echo "Validator accepted a changelog carrying: $text" >&2
      exit 1
    fi
    reason="$("$REPO_ROOT/hack/validate-changelog.sh" "$tmp/bad.md" 1.5.1 2>&1 || true)"
    case "$reason" in
      *"$want"*"(line(s) $line)"*) ;;
      *) echo "Rejected '$text' without naming '$want' and line $line: $reason" >&2; exit 1 ;;
    esac
    [ "$(printf '%s\n' "$reason" | wc -l)" -eq 1 ] || {
      echo "The reason spans several lines, so a workflow annotation would carry only the first:" >&2
      printf '%s\n' "$reason" | sed 's/^/    /' >&2
      exit 1
    }
  done

  # The website source tree named in prose (v1.3.0), an ellipsis inside a
  # sentence (v1.4.0), a list closed with "..." right before the attribution,
  # and a versioned docs link.
  {
    cat "$base"
    printf '%s\n' \
      '* **[website] Trunk docs**: keeps a permanent `content/en/docs/next/` trunk ([**@someone**](https://github.com/someone) in cozystack/website#1)' \
      '* **feat(app): sizes**: the legacy names (`nano` … `2xlarge`) stay as aliases ([**@someone**](https://github.com/someone) in #2)' \
      '* **feat(app): plugins**: stages the reference plugins (bridge, macvlan, ...) ([**@someone**](https://github.com/someone) in #3)' \
      'Learn more in the [documentation](https://cozystack.io/docs/v1.5/operations/).'
  } > "$tmp/near.md"
  "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/near.md" 1.5.1 >/dev/null 2>&1 || {
    echo "Validator rejected text that only resembles a truncated entry or a docs/next link:" >&2
    "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/near.md" 1.5.1 2>&1 >/dev/null | sed 's/^/    /' >&2
    exit 1
  }
  rm -rf "$tmp"
}

# 12. The workflow must actually call the script. Keeping the predicates in a
#     tested script helps nothing if the step quietly reverts to an inline check.
#     Now in the reusable generation core.
@test "the generation core calls the changelog validator script" {
  block="$(job_block changelog "$GENERATE")"
  printf '%s\n' "$block" | grep -q 'hack/validate-changelog.sh' || {
    echo "The Verify changelog step no longer calls hack/validate-changelog.sh." >&2
    echo "An inline reimplementation would not be covered by tests 9-11." >&2
    exit 1
  }
  # And a rejected changelog must not fail the job — generation is non-blocking.
  printf '%s\n' "$block" | grep -q "present=false" || {
    echo "The Verify changelog step no longer has a present=false path; a rejected" >&2
    echo "changelog would fail the job instead of degrading gracefully." >&2
    exit 1
  }
}

# 13. The agent reads docs/agents/changelog.md from the WORKING TREE, which is the
#     rc tag — a commit that predates this workflow. Every rc cut before this
#     change carries the old §2, which lists only two valid configurations and
#     tells the agent to STOP on anything else; a detached rc-tag checkout is
#     exactly that. Without the overlay a compliant agent declines to generate on
#     every promotion of an existing rc, silently. hack/validate-changelog.sh is
#     absent from those trees for the same reason, and the Verify step would then
#     die on "command not found".
#
#     Asserting the doc's content on the current branch (test 5) is necessary but
#     NOT sufficient — that copy is not the one the agent reads.
@test "the generation core overlays the agent instructions and validator from the dispatch ref" {
  block="$(job_block changelog "$GENERATE")"

  overlay="$(printf '%s\n' "$block" | awk '
    /^      - name: Refresh agent instructions$/ { inside = 1; next }
    /^      - name: / { inside = 0 }
    inside')"
  [ -n "$overlay" ] || {
    echo "The changelog job has no step refreshing docs/agents/changelog.md from the" >&2
    echo "dispatch ref. Promoting any rc tagged before this change would present the" >&2
    echo "agent with instructions that reject its own checkout state." >&2
    exit 1
  }

  printf '%s\n' "$overlay" | grep -q 'cp .release-tooling/docs/agents/changelog.md' || {
    echo "The refresh step no longer overlays docs/agents/changelog.md from the" >&2
    echo "tooling checkout." >&2
    exit 1
  }

  # changelog.md sends the agent to run the docs/release/changelogs.md checks
  # and the validator on its own output, by their working-tree paths. Without
  # these two copies it runs the rc tag's versions, which predate every check
  # added since.
  for f in docs/release/changelogs.md hack/validate-changelog.sh; do
    printf '%s\n' "$overlay" | code_lines | grep -qF "cp .release-tooling/$f $f" || {
      echo "The refresh step does not overlay $f from the tooling checkout, so the" >&2
      echo "agent following docs/agents/changelog.md section 9 runs the rc tag's copy." >&2
      exit 1
    }
  done

  # An rc tagged before the release docs were split has no docs/release/
  # directory, and cp does not create one.
  printf '%s\n' "$overlay" | code_lines | grep -qE '^[[:space:]]+mkdir -p docs/release$' || {
    echo "The refresh step does not create docs/release before copying into it, so" >&2
    echo "the copy fails on every rc tagged before the release docs were split." >&2
    exit 1
  }

  # The tooling checkout has to list both directories the copies read from.
  tooling="$(printf '%s\n' "$block" | awk '
    /^      - name: Checkout release tooling from the dispatch ref$/ { inside = 1; next }
    /^      - name: / { inside = 0 }
    inside' | code_lines)"
  for d in docs/agents docs/release; do
    printf '%s\n' "$tooling" | grep -qE "^[[:space:]]+$d\$" || {
      echo "The tooling checkout no longer lists $d, so .release-tooling lacks the" >&2
      echo "file the refresh step copies from it." >&2
      exit 1
    }
  done

  # Every job that runs release tooling against an older checkout must source it
  # from the dispatch ref. A script fetched by a script that is itself missing
  # solves nothing, so this is done by a second actions/checkout, not by shell.
  # The generation core carries it; promote / open-pr (which run tooling against
  # the rc tree) carry it; changelog-rc's commit job (which runs the validator
  # against the rc staging branch) carries it.
  for pair in "promote:$PROMOTE" "open-pr:$PROMOTE" "changelog:$GENERATE" "commit:$RC_WRAPPER"; do
    job="${pair%%:*}"; file="${pair#*:}"
    jb="$(job_block "$job" "$file")"
    printf '%s\n' "$jb" | grep -q 'path: .release-tooling' || {
      echo "Job '$job' in $(basename "$file") has no .release-tooling checkout, but runs" >&2
      echo "tooling that the older checkout does not carry — it would fail with" >&2
      echo "'command not found'." >&2
      exit 1
    }
  done

  # And that scratch path must never be committed: promote runs `git add -A`.
  grep -qE '^\.release-tooling' "$REPO_ROOT/.gitignore" || {
    echo ".release-tooling is not gitignored; promote's 'git add -A' would commit" >&2
    echo "the entire tooling checkout onto the release branch." >&2
    exit 1
  }
}

# 14. The non-blocking guarantee has to survive the whole job, not just the
#     job-level `if:`. A fatal artifact download would stop open-pr before the PR
#     step — the same wedge, one step further down — and the PR-body/commit
#     decisions must key off what is actually on disk rather than an upstream
#     job's output, which says nothing about whether the transfer succeeded.
@test "open-pr survives an artifact transfer failure and keys off the file on disk" {
  block="$(job_block open-pr "$PROMOTE")"

  dl="$(printf '%s\n' "$block" | awk '
    /^      - name: Download changelog artifact$/ { inside = 1; next }
    /^      - name: / { inside = 0 }
    inside')"
  [ -n "$dl" ] || { echo "Could not locate the Download changelog artifact step" >&2; exit 1; }
  printf '%s\n' "$dl" | grep -q 'continue-on-error: true' || {
    echo "The artifact download is fatal. A transient transfer failure would abort" >&2
    echo "open-pr before the PR is created, leaving a pushed staging branch and a" >&2
    echo "drafted release with no promote PR." >&2
    exit 1
  }

  printf '%s\n' "$block" | grep -q 'Confirm changelog on disk' || {
    echo "open-pr no longer re-establishes changelog presence from the filesystem" >&2
    echo "after the download." >&2
    exit 1
  }

  # The commit and the PR body must both read the on-disk result.
  printf '%s\n' "$block" | grep -qE "steps\.usable\.outputs\.present == 'true'" || {
    echo "The commit step no longer gates on steps.usable (the on-disk check)." >&2
    exit 1
  }
  printf '%s\n' "$block" | grep -q 'steps.usable.outputs.present' || {
    echo "The PR body no longer reflects the on-disk changelog state — it could" >&2
    echo "claim ✅ for a changelog that never arrived." >&2
    exit 1
  }
  printf '%s\n' "$block" | grep -qE "CHANGELOG_PRESENT:.*needs\.changelog\.outputs\.present" && {
    echo "The PR body still keys off needs.changelog.outputs.present, which is true" >&2
    echo "even when the artifact failed to transfer." >&2
    exit 1
  }
  return 0
}

# 15. A pre-existing changelog reaches open-pr by routes that skip generation
#     entirely — hand-written into the rc tree, or preserved by promote across the
#     force-push — so nothing else has validated it. Accepting "non-empty" there
#     would let a truncated hand-commit be advertised with ✅ and published
#     verbatim as the release body, bypassing the validator completely.
@test "a pre-existing changelog is validated, not merely non-empty" {
  block="$(job_block open-pr "$PROMOTE")"
  existing="$(printf '%s\n' "$block" | awk '
    /^      - name: Check for an existing changelog on the branch$/ { inside = 1; next }
    /^      - name: / { inside = 0 }
    inside')"
  [ -n "$existing" ] || { echo "Could not locate the existing-changelog check" >&2; exit 1; }
  printf '%s\n' "$existing" | grep -q 'hack/validate-changelog.sh' || {
    echo "The existing-changelog check accepts any non-empty file without validating" >&2
    echo "it. A truncated hand-commit would be published as the release body." >&2
    exit 1
  }

  # The backstop has the same exposure: it commits either a ported or a generated
  # file, and update-releasenotes.yaml pushes whatever merges into the release body.
  tags_block="$(job_block generate-changelog "$TAGS")"
  printf '%s\n' "$tags_block" | grep -q 'hack/validate-changelog.sh' || {
    echo "The tags.yaml backstop no longer validates the changelog before opening" >&2
    echo "its PR." >&2
    exit 1
  }
}

# 16. promote-rc parses the rc tag ONCE, in a cheap `parse` job that REJECTS
#     whitespace (not `.trim()`), and its changelog job is a thin `uses:` of the
#     reusable core fed that validated tag. Before this, promote's parse trimmed-
#     and-accepted a whitespace-bearing tag while the parallel changelog job (raw
#     input, whitespace-rejecting) refused it — so promote pushed the stable
#     branch and open-pr opened a PR with NO changelog. One parse, one policy.
@test "promote-rc parses once (rejecting whitespace) and feeds the reusable core the validated tag" {
  grep -qE '^on:' "$GENERATE" && grep -q 'workflow_call:' "$GENERATE" || {
    echo "changelog-generate.yaml is not a workflow_call reusable workflow." >&2
    exit 1
  }

  # A dedicated parse job that rejects whitespace outright (no trim-and-accept).
  parse_block="$(job_block parse "$PROMOTE")"
  [ -n "$parse_block" ] || { echo "promote-rc has no top-level parse job" >&2; exit 1; }
  printf '%s\n' "$parse_block" | grep -qF '/\s/.test(rc)' || {
    echo "promote-rc's parse no longer rejects whitespace in the rc tag. A trimmed-" >&2
    echo "and-accepted tag would be pushed by promote while the changelog job rejects" >&2
    echo "it — a promote PR with no changelog." >&2
    exit 1
  }
  printf '%s\n' "$parse_block" | grep -qF '.trim()' && {
    echo "promote-rc's parse still trims the rc tag (trim-and-accept). It must reject" >&2
    echo "whitespace outright so its policy matches the changelog job's." >&2
    exit 1
  }

  block="$(job_block changelog "$PROMOTE")"
  [ -n "$block" ] || { echo "Could not locate the changelog job in $PROMOTE" >&2; exit 1; }
  printf '%s\n' "$block" | grep -qE 'uses: \./\.github/workflows/changelog-generate\.yaml' || {
    echo "promote-rc's changelog job no longer calls the reusable changelog-generate.yaml." >&2
    echo "Re-inlining the steps drifts the promote-time and rc-time generation paths apart." >&2
    exit 1
  }
  # The steps live in the reusable workflow now, not here.
  printf '%s\n' "$block" | grep -qE '^\s*steps:' && {
    echo "promote-rc's changelog job declares its own steps AND a uses: — invalid," >&2
    echo "and a sign the extraction was only half done." >&2
    exit 1
  }
  # It must depend on parse and be handed the VALIDATED tag, never raw input.
  printf '%s\n' "$block" | code_lines | grep -qE '^\s+needs: parse$' || {
    echo "promote-rc's changelog job does not declare 'needs: parse' — it would not" >&2
    echo "receive the validated tag and would race promote's parse." >&2
    exit 1
  }
  printf '%s\n' "$block" | grep -qE 'rc_tag: \$\{\{ needs\.parse\.outputs\.rc_tag \}\}' || {
    echo "promote-rc's changelog job is not fed needs.parse.outputs.rc_tag." >&2
    exit 1
  }
  printf '%s\n' "$block" | grep -qE 'rc_tag: \$\{\{ inputs\.rc_tag \}\}' && {
    echo "promote-rc's changelog job still passes raw inputs.rc_tag to the reusable" >&2
    echo "core instead of the validated parse output." >&2
    exit 1
  }
  # promote must also depend on parse (so the whole promotion aborts on a bad tag).
  printf '%s\n' "$(job_block promote "$PROMOTE")" | code_lines | grep -qE '^\s+needs: parse$' || {
    echo "promote-rc's promote job does not declare 'needs: parse'." >&2
    exit 1
  }

  # The AI secret pair (and the Copilot token) must be forwarded, or generation
  # cannot mint its read-only token.
  for s in COZYSTACK_CI_APP_ID COZYSTACK_CI_PRIVATE_KEY COPILOT_GITHUB_TOKEN; do
    printf '%s\n' "$block" | grep -q "$s:" || {
      echo "promote-rc's changelog job does not pass secret '$s' to the reusable core." >&2
      exit 1
    }
  done

  # The reusable core must actually declare those three secrets, or a caller that
  # forgets one would fail only at the AI step rather than at parse time.
  for s in COZYSTACK_CI_APP_ID COZYSTACK_CI_PRIVATE_KEY COPILOT_GITHUB_TOKEN; do
    grep -q "$s:" "$GENERATE" || {
      echo "changelog-generate.yaml does not declare secret '$s' in its workflow_call interface." >&2
      exit 1
    }
  done
}

# 17. open-pr consumes the reusable core's `present` output through
#     needs.changelog.outputs.present. If the reusable workflow stops exporting it,
#     open-pr's download gate silently reads empty and never fetches the artifact.
@test "the reusable core exposes present and open-pr still keys off it" {
  # Declared as a workflow_call output mapped from the job.
  grep -qE '^\s+present:' "$GENERATE" || {
    echo "changelog-generate.yaml no longer declares a 'present' output." >&2
    exit 1
  }
  grep -q 'value: ${{ jobs.changelog.outputs.present }}' "$GENERATE" || {
    echo "changelog-generate.yaml's present output is not mapped from the job's output." >&2
    exit 1
  }

  open_block="$(job_block open-pr "$PROMOTE")"
  printf '%s\n' "$open_block" | grep -qE "needs\.changelog\.outputs\.present == 'true'" || {
    echo "open-pr's Download step no longer gates on needs.changelog.outputs.present." >&2
    echo "It would try to download the artifact even when none was produced." >&2
    exit 1
  }
}

# 18. The reusable core delegates changelog-source selection to the extracted,
#     behaviour-tested hack/select-changelog-source.sh — validate-then-fall-
#     through across rc tag tree -> rc staging branch -> generate. The LOGIC is
#     proven by execution in hack/release-changelog-behaviour.bats; here we pin
#     that the script exists, still embodies the fall-through in the right order,
#     and is actually wired into the check step on an executable line (not a
#     comment, and not reverted to a fragile inline copy).
@test "the generation core delegates source selection to the extracted script" {
  script="$REPO_ROOT/hack/select-changelog-source.sh"
  [ -x "$script" ] || { echo "hack/select-changelog-source.sh missing or not executable" >&2; exit 1; }

  grep -q 'changelog-preserve.sh' "$script" || {
    echo "select-changelog-source.sh no longer fetches the rc staging branch." >&2; exit 1; }
  grep -q 'validate-changelog.sh' "$script" || {
    echo "select-changelog-source.sh no longer validates each source." >&2; exit 1; }
  grep -q 'rm -f' "$script" || {
    echo "select-changelog-source.sh no longer discards an invalid source — it could" >&2
    echo "then suppress a valid lower-priority one." >&2; exit 1; }

  # Priority: the rc-tag-tree check must precede the rc-staging-branch fetch. Key
  # on the actual code — the `-s "$CL"` test and the `"$PRESERVE"` invocation —
  # over comment-stripped lines, so the `changelog-preserve.sh` mention in the
  # header/assignment does not skew the order.
  tag_line="$(code_lines < "$script" | grep -n '\-s "\$CL"' | head -1 | cut -d: -f1)"
  branch_line="$(code_lines < "$script" | grep -n '"\$PRESERVE"' | head -1 | cut -d: -f1)"
  [ -n "$tag_line" ] && [ -n "$branch_line" ] && [ "$tag_line" -lt "$branch_line" ] || {
    echo "select-changelog-source.sh does not check the rc tag tree before the rc" >&2
    echo "staging branch — a stale branch file could mask a hand-written tag-tree one." >&2
    exit 1
  }

  # The reusable core must actually call it, exactly once, on an executable line.
  check="$(job_block changelog "$GENERATE" | awk '
    /^      - name: Check if changelog already exists$/ { inside = 1; next }
    /^      - name: / { inside = 0 }
    inside')"
  [ -n "$check" ] || { echo "Could not locate the skip-check step in the generation core" >&2; exit 1; }
  [ "$(printf '%s\n' "$check" | code_lines | grep -cF 'select-changelog-source.sh')" -eq 1 ] || {
    echo "The generation core's check step does not call select-changelog-source.sh" >&2
    echo "exactly once on an executable line (a comment, or a reverted inline copy)." >&2
    exit 1
  }
}

# 19. The rc-time button is a maintainer-triggered entry point taking a free-form
#     tag. It must validate against the strict rc pattern via the shared,
#     behaviour-tested hack/parse-rc-tag.sh (which REJECTS whitespace rather than
#     stripping it — see finding 2 / the behaviour suite) before anything mints a
#     token, checks out a ref, or touches git.
@test "changelog-rc validates the rc tag via the shared strict parser" {
  script="$REPO_ROOT/hack/parse-rc-tag.sh"
  [ -x "$script" ] || { echo "hack/parse-rc-tag.sh missing or not executable" >&2; exit 1; }

  # The script is the strict gate: anchored rc regex + whitespace rejection.
  grep -qE '\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+-rc\\\.\[0-9\]\+\$' "$script" || {
    echo "parse-rc-tag.sh no longer anchors the rc regex ^vX.Y.Z-rc.N\$." >&2
    exit 1
  }
  grep -q 'must not contain whitespace' "$script" || {
    echo "parse-rc-tag.sh no longer rejects whitespace outright — a stripped variant" >&2
    echo "would land in a different concurrency lane racing to push the same branch." >&2
    exit 1
  }

  # changelog-rc's parse job must actually call it.
  parse_block="$(job_block parse "$RC_WRAPPER")"
  printf '%s\n' "$parse_block" | grep -q 'parse-rc-tag.sh' || {
    echo "changelog-rc's parse job no longer calls hack/parse-rc-tag.sh — a divergent" >&2
    echo "inline copy would not be covered by the behaviour suite." >&2
    exit 1
  }

  # The reusable core inlines the same rules (its parse runs before any checkout,
  # so it cannot call the script) — pin that it too rejects whitespace.
  gen_parse="$(job_block changelog "$GENERATE")"
  printf '%s\n' "$gen_parse" | grep -q 'must not contain whitespace' || {
    echo "The reusable core's parse no longer rejects whitespace (it must mirror" >&2
    echo "parse-rc-tag.sh); a stripped tag could be silently rewritten." >&2
    exit 1
  }

  # No consumer may check out or act on raw dispatch input.
  commit_block="$(job_block commit "$RC_WRAPPER")"
  printf '%s\n' "$commit_block" | grep -qE 'ref: \$\{\{ inputs\.rc_tag \}\}' && {
    echo "changelog-rc's commit job checks out raw inputs.rc_tag rather than the" >&2
    echo "reconstructed parse output." >&2
    exit 1
  }
  return 0
}

# 20. The rc-time button must call the SAME reusable core promote-rc uses — a
#     forked second copy of the generation steps would drift and defeat the whole
#     point of the extraction.
@test "changelog-rc calls the shared reusable generation core" {
  gen_block="$(job_block generate "$RC_WRAPPER")"
  [ -n "$gen_block" ] || { echo "Could not locate the generate job in $RC_WRAPPER" >&2; exit 1; }
  printf '%s\n' "$gen_block" | grep -qE 'uses: \./\.github/workflows/changelog-generate\.yaml' || {
    echo "changelog-rc.yaml does not call the reusable changelog-generate.yaml — it" >&2
    echo "has forked the generation logic instead of sharing it with promote-rc." >&2
    exit 1
  }
  for s in COZYSTACK_CI_APP_ID COZYSTACK_CI_PRIVATE_KEY COPILOT_GITHUB_TOKEN; do
    printf '%s\n' "$gen_block" | grep -q "$s:" || {
      echo "changelog-rc's generate job does not pass secret '$s' to the reusable core." >&2
      exit 1
    }
  done
}

# 21. Finding 1: the rc staging branch existence is validated UP FRONT — in the
#     parse job, which `generate` depends on — so a missing branch fails in
#     seconds rather than after a 30-minute AI generation whose output has nowhere
#     to land. The check must be structurally before generation, not merely
#     present somewhere.
@test "changelog-rc verifies the rc staging branch up front, before generation" {
  # rc_branch is derived by the shared parser, and the branch check runs in parse.
  grep -qE 'rc_branch=release-' "$REPO_ROOT/hack/parse-rc-tag.sh" || {
    echo "hack/parse-rc-tag.sh no longer derives the rc staging branch release-X.Y.Z-rc.N." >&2
    exit 1
  }

  parse_block="$(job_block parse "$RC_WRAPPER")"
  [ -n "$parse_block" ] || { echo "Could not locate the parse job in $RC_WRAPPER" >&2; exit 1; }

  # The clear, dedicated missing-branch failure lives in parse (not commit), on an
  # executable line (code_lines strips comments) and probed with git ls-remote.
  printf '%s\n' "$parse_block" | code_lines | grep -qi 'staging branch.* does not exist' || {
    echo "changelog-rc's parse job has no clear 'rc staging branch does not exist'" >&2
    echo "failure on an executable line — a missing branch would surface only as a" >&2
    echo "generic checkout error, and only AFTER generation ran." >&2
    exit 1
  }
  printf '%s\n' "$parse_block" | code_lines | grep -q 'git ls-remote' || {
    echo "changelog-rc's parse job no longer probes the branch with git ls-remote." >&2
    exit 1
  }

  # Structural order: generate DEPENDS on parse (executable line, exactly once),
  # so the branch check (a parse step) is guaranteed to run — and pass — before
  # generation ever starts. A comment or dead block cannot satisfy this.
  gen_block="$(job_block generate "$RC_WRAPPER")"
  [ "$(printf '%s\n' "$gen_block" | code_lines | grep -cE '^[[:space:]]+needs: parse$')" -eq 1 ] || {
    echo "changelog-rc's generate job does not declare 'needs: parse' exactly once on an" >&2
    echo "executable line. The up-front branch check would then not gate generation." >&2
    exit 1
  }
  # And the branch check must NOT have regressed back into the commit job only.
  commit_block="$(job_block commit "$RC_WRAPPER")"
  printf '%s\n' "$commit_block" | code_lines | grep -qi 'staging branch.* does not exist' && {
    echo "The missing-branch check is in the commit job — that runs AFTER generation," >&2
    echo "reintroducing the late-validation defect. It belongs in parse." >&2
    exit 1
  }
  return 0
}

# 22. The rc-time result is banked on the rc STAGING branch release-X.Y.Z-rc.N as
#     docs/changelogs/vX.Y.Z.md — the stable name promote's pickup and finalize
#     both expect. The commit must be idempotent (re-pressing the button must not
#     pile up duplicate commits), validated, and hard-fail on an empty result.
@test "changelog-rc commits to the rc staging branch idempotently and validated" {
  commit_block="$(job_block commit "$RC_WRAPPER")"
  [ -n "$commit_block" ] || { echo "Could not locate the commit job in $RC_WRAPPER" >&2; exit 1; }

  # It pushes to the rc staging branch (release-*-rc.*), not the stable one —
  # exactly once, on an executable line (a comment or dead `if false` block that
  # merely contains the string cannot satisfy this).
  [ "$(printf '%s\n' "$commit_block" | code_lines | grep -cF 'git push origin "HEAD:refs/heads/${RC_BRANCH}"')" -eq 1 ] || {
    echo "changelog-rc's commit job no longer pushes to the rc staging branch exactly" >&2
    echo "once on an executable line." >&2
    exit 1
  }

  # Compare-before-push idempotency: an unchanged file must be a green no-op.
  printf '%s\n' "$commit_block" | code_lines | grep -qF 'git diff --cached --quiet' || {
    echo "changelog-rc's commit is not idempotent — re-pressing the button would pile" >&2
    echo "up duplicate 'docs: add changelog' commits on the rc staging branch." >&2
    exit 1
  }

  # It validates before committing (same gate as the reusable core / open-pr).
  printf '%s\n' "$commit_block" | grep -q 'hack/validate-changelog.sh' || {
    echo "changelog-rc commits without validating — a truncated file could be banked" >&2
    echo "on the rc staging branch and later published verbatim." >&2
    exit 1
  }

  # An empty generation result must fail LOUDLY here (unlike open-pr's silent
  # degrade), because this is an explicit maintainer action.
  printf '%s\n' "$commit_block" | grep -qE "PRESENT.*!= .true." || {
    echo "changelog-rc's commit job does not hard-fail when generation produced no" >&2
    echo "usable changelog — the maintainer would get a green run and no committed file." >&2
    exit 1
  }
}

# 23. The artifact is the hand-off between the generation core and BOTH consumers.
#     A name mismatch means the download silently finds nothing and the changelog
#     never lands. Pin that producer and both consumers use the identical name.
@test "the changelog artifact name is identical across producer and both consumers" {
  # Producer: the reusable core's upload step.
  grep -q 'name: changelog-v${{ steps.parse.outputs.stable_version }}' "$GENERATE" || {
    echo "changelog-generate.yaml no longer uploads the artifact as changelog-vX.Y.Z." >&2
    exit 1
  }
  # Consumer 1: promote-rc's open-pr download.
  grep -q 'name: changelog-v${{ needs.promote.outputs.stable_version }}' "$PROMOTE" || {
    echo "promote-rc's open-pr no longer downloads the artifact by the changelog-vX.Y.Z name." >&2
    exit 1
  }
  # Consumer 2: changelog-rc's commit download.
  grep -q 'name: changelog-v${{ needs.parse.outputs.stable_version }}' "$RC_WRAPPER" || {
    echo "changelog-rc's commit job no longer downloads the artifact by the changelog-vX.Y.Z name." >&2
    exit 1
  }
}