Files
cozystack/hack/release-changelog-contract.bats
Aleksei Sviridkin 575b41e1cd test(hack): let each bats file declare the EXIT traps it still holds
The ban on EXIT-trap cleanup in hack/*.bats was enforced by two lists
inside hack/cozyreport.bats: one naming the files known to be clean, one
freezing an exact trap count for each file that was not. Both had to be
edited from whatever change moved them, and that is where the guard kept
failing. A change adding a trap to its own file had to edit a string in
a suite it otherwise never touches, so two changes sharing no line still
invalidated each other: each stayed green against its own base, git
merged both cleanly, and the guard went red only once the second landed.
A file arriving with traps hit the same wall from the other side -- the
inventory did notice it, since the string it compared was built by
scanning the directory, but absorbing it meant an edit in a file nothing
in the author's diff pointed at.

Replace both lists with a declaration each file makes about itself: one
"# EXIT-TRAP DEBT: N" comment, exact rather than a ceiling. A file
carrying none must install no EXIT trap, which covers the converted
files, the files that never had one, and every file added later.
Growing or shedding a trap now fails in the file the change already
edits, so two changes that disagree about a count collide textually
instead of silently.

The declaration is read from the leading comment block, not from
anywhere in the file. A .bats file is shell that writes shell, so the
same line turns up inside a heredoc, a fixture writer or an
expected-output string, where it is data belonging to one test. Reading
it there as a statement about the whole file would let an unrelated
fixture excuse a real trap, and would do it silently, since nothing in
that test's own diff looks like a declaration. A comment block is the
region with no interior: stopping instead at the first @test would still
read a line out of a helper's heredoc.

Not every counted handler is debt. A trap inside an explicit subshell
does not replace the bats binary's own, so a test failing inside
`( ... )` still prints its `not ok`. hack/e2e-test-openapi.bats kills a
backgrounded kubectl proxy that way, and moving the kill to the end of
the body would leak a process holding a fixed port. Its declaration
records that rather than scheduling a conversion, and because the
ratchet is exact, removing the trap fails too -- the count protects the
construct. What the count cannot do is tell the two apart: substituting
a test-level trap for the subshell one keeps the total at 1 and stays
green, which the header states rather than leaves to be found.

Counting bounds the keyword and the signal the same way, at any
character that cannot be part of an identifier, and matches the signal
in either case. Whitespace on the right missed `trap ... EXIT; cd
"$tmp"`; whitespace on the left missed `tmp=$(mktemp -d);trap ... EXIT`
and `(trap ... EXIT; true)`; upper case missed `trap ... exit`, which
bash and dash both install. All are real handlers that scored zero, and
the left boundary matters most, since the inventory being replaced had
none and caught the semicolon form. A bare word boundary is not enough
either way: `bootstrap ` ends in `trap `, and it must keep scoring
nothing. A quoted signal counts for the same reason as the rest.

Two handlers sharing one line are reported rather than counted, since
the count is a count of lines and the second would otherwise arrive
without moving the total; splitting the line properly needs a shell
parser, a semicolon inside a handler's own action not being a
separator, and guessing wrong undercounts -- the one direction a ratchet
cannot afford.

The scan recurses, so hack/e2e-apps/*.bats is covered rather than
sitting one directory below the guard that claims the tree. It reads
.bats and nothing else, so a handler arriving through a sourced .sh
stays outside it -- hack/e2e-chainsaw/_lib/run-kubernetes.sh installs
two, each benign for its own reason rather than by design: one sits in
a function declared with `(` and so runs in a subshell, the other in a
brace function no @test calls. That boundary is stated in the header
rather than papered over.

The guard moves to hack/bats-no-exit-trap.bats: its subject is every
unit suite under hack/, not the report collector it grew up in. Its
fixture helpers assemble the trap keyword and the signal from separate
arguments, because the guard scans its own source and a fixture written
as a literal would be counted as a real trap in it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-07 23:53:40 +03:00

897 lines
45 KiB
Bash

#!/usr/bin/env bats
# EXIT-TRAP DEBT: 1 -- see hack/bats-no-exit-trap.bats; lower it as the traps go, delete it at zero.
# 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.
# 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.
# 12. The workflow calls the validator script rather than reimplementing it.
# 13. The agent instructions 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.
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"
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
}
}
# 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)"
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" EXIT
printf ' \n\n \n' > "$tmp/whitespace.md"
! "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/whitespace.md" 1.6.0 >/dev/null 2>&1 || {
echo "Validator accepted a whitespace-only file — it would publish as blank release notes." >&2
exit 1
}
: > "$tmp/empty.md"
! "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/empty.md" 1.6.0 >/dev/null 2>&1 || {
echo "Validator accepted an empty file." >&2; exit 1; }
! "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/absent.md" 1.6.0 >/dev/null 2>&1 || {
echo "Validator accepted a nonexistent file." >&2; exit 1; }
# 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"
! "$REPO_ROOT/hack/validate-changelog.sh" "$tmp/truncated.md" 1.5.1 >/dev/null 2>&1 || {
echo "Validator accepted a truncated changelog — a fragment would be published" >&2
echo "verbatim as the release body." >&2
exit 1
}
# 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.
! "$REPO_ROOT/hack/validate-changelog.sh" "$REPO_ROOT/docs/changelogs/v1.5.1.md" 1.6.0 >/dev/null 2>&1 || {
echo "Validator accepted v1.5.1's changelog as v1.6.0's release body." >&2
exit 1
}
}
# 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
}
# 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
}
}