hack/release-changelog-contract.bats
Rama main · réplica de lectura del repositorio original.
#!/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
}
}