mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-04 02:57:27 +09:00
Finalize sets the release body after the stable tag, the api/apps/v1alpha1 tag and the release-X.Y fast-forward already exist, so a body GitHub refuses fails the step with the release still a draft, and the image retag and chart publish behind it never run. GitHub's web UI cut the v1.7.0-rc.2 changelog at about 125000 characters, and the v1.7.0 changelog is 259,026. A release can now carry a short hand-written docs/releasenotes/<tag>.md that becomes its body, while the changelog stays the full record. Both finalize and update-releasenotes take the release notes when they have content and the changelog otherwise, and hold the body to 100000 bytes. Bytes are never fewer than characters, and the margin makes GitHub's exact limit, and its answer to an oversized body, irrelevant. Over the limit, finalize publishes a one-line link to the changelog on the tag instead, and the sync leaves the published body alone: the tags.yaml backstop brings a changelog to main without its release notes, and the sync would otherwise replace them. A release without release notes whose changelog is under the limit still gets its changelog byte for byte. Assisted-by: LLM Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
717 lines
31 KiB
Bash
717 lines
31 KiB
Bash
#!/usr/bin/env bats
|
|
# EXECUTES the release-changelog logic instead of asserting about its YAML.
|
|
#
|
|
# Its companion, hack/release-changelog-contract.bats, greps the workflow files.
|
|
# That catches a rename or a deleted `if:`, and nothing else: it cannot tell
|
|
# whether the shell inside a `run:` block does what the step name claims, and it
|
|
# stays green while the logic underneath is wrong. Everything here runs for real
|
|
# against throwaway git repositories and asserts on outcomes.
|
|
#
|
|
# What is genuinely covered:
|
|
#
|
|
# * hack/changelog-preserve.sh against real remotes — a branch that exists, one
|
|
# that does not, one with no changelog, one with an empty changelog, and the
|
|
# full force-push cycle promote actually performs.
|
|
# * hack/validate-changelog.sh against every current-era changelog in the tree
|
|
# plus hand-built truncated / wrong-version / whitespace inputs.
|
|
# * The github-script steps that set a release body, finalize's and
|
|
# update-releasenotes', taken verbatim from the workflows and run under node
|
|
# against fixture trees.
|
|
# * The GitHub Actions `needs` + `if:` semantics the whole non-blocking design
|
|
# rests on, run under `act` when Docker is available (see
|
|
# hack/testdata/needs-semantics.yaml).
|
|
#
|
|
# Still NOT covered, and worth saying plainly:
|
|
#
|
|
# * The AI generation step. Unobservable without Copilot and a paid run.
|
|
# * The real GitHub API calls — gh pr create/edit, updateRelease. Only their
|
|
# surrounding shell is exercised.
|
|
# * That the sparse tooling checkout resolves on GitHub's runners. actions/
|
|
# checkout is not runnable here; the contract suite pins its presence.
|
|
#
|
|
# 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)"
|
|
PRESERVE="$REPO_ROOT/hack/changelog-preserve.sh"
|
|
VALIDATE="$REPO_ROOT/hack/validate-changelog.sh"
|
|
PARSE_RC="$REPO_ROOT/hack/parse-rc-tag.sh"
|
|
SELECT="$REPO_ROOT/hack/select-changelog-source.sh"
|
|
PROMOTE="$REPO_ROOT/.github/workflows/promote-rc.yaml"
|
|
FINALIZE="$REPO_ROOT/.github/workflows/pull-requests-release.yaml"
|
|
SYNC="$REPO_ROOT/.github/workflows/update-releasenotes.yaml"
|
|
|
|
# Body of a top-level workflow job, so an assertion about one job cannot be
|
|
# satisfied — or broken — by an unrelated one. Same shape as the contract suite's.
|
|
job_block() {
|
|
awk -v job=" $1:" '
|
|
$0 == job { inside = 1; next }
|
|
/^ [a-z0-9_-]+:$/ { inside = 0 }
|
|
inside' "$2"
|
|
}
|
|
|
|
# One named step, for pins about a specific script rather than about a job. The
|
|
# parse job is ~180 lines, most of them the e2e gate, so a job-scoped negative
|
|
# pin still fires on string handling that has nothing to do with tag policy.
|
|
step_block() {
|
|
awk -v name=" - name: $1" '
|
|
$0 == name { inside = 1; next }
|
|
/^ - name: / { inside = 0 }
|
|
inside' "$2"
|
|
}
|
|
|
|
# Executable lines only: a YAML comment mentioning the pinned pattern must not
|
|
# satisfy — or break — a pin. Same helper the contract suite uses.
|
|
code_lines() {
|
|
local rc=0
|
|
grep -v '^[[:space:]]*#' || rc=$?
|
|
[ "$rc" -le 1 ]
|
|
}
|
|
|
|
# A bare "remote" plus a working clone, wired the way promote-rc.yaml sees them.
|
|
# Everything lives under one temp dir the caller removes.
|
|
make_fixture() {
|
|
root="$1"
|
|
mkdir -p "$root/remote" "$root/work"
|
|
git init -q --bare "$root/remote"
|
|
git init -q "$root/work"
|
|
(
|
|
cd "$root/work"
|
|
git config user.email ci@example.invalid
|
|
git config user.name CI
|
|
git config commit.gpgsign false
|
|
git remote add origin "$root/remote"
|
|
mkdir -p docs/changelogs
|
|
echo "seed" > README.md
|
|
git add -A
|
|
git commit -qm "seed"
|
|
git push -q origin HEAD:refs/heads/main
|
|
)
|
|
}
|
|
|
|
# A changelog that hack/validate-changelog.sh accepts, so fixtures exercise the
|
|
# preserve path rather than tripping validation for unrelated reasons.
|
|
write_changelog() {
|
|
dest="$1"; version="$2"
|
|
mkdir -p "$(dirname "$dest")"
|
|
cat > "$dest" <<EOF
|
|
<!--
|
|
https://github.com/cozystack/cozystack/releases/tag/v${version}
|
|
-->
|
|
|
|
# v${version} (2026-07-22)
|
|
|
|
Fixture changelog.
|
|
|
|
## Fixes
|
|
|
|
* **Something**: description ([**@someone**](https://github.com/someone) in #1).
|
|
|
|
**Full Changelog**: https://github.com/cozystack/cozystack/compare/v1.0.0...v${version}
|
|
EOF
|
|
}
|
|
|
|
# A changelog that hack/validate-changelog.sh REJECTS: header + release-link
|
|
# comment but no '## ' section and no compare link — the realistic shape of a
|
|
# truncated AI run or a botched hand-commit.
|
|
write_invalid_changelog() {
|
|
dest="$1"; version="$2"
|
|
mkdir -p "$(dirname "$dest")"
|
|
cat > "$dest" <<EOF
|
|
<!--
|
|
https://github.com/cozystack/cozystack/releases/tag/v${version}
|
|
-->
|
|
|
|
# v${version} (2026-07-24)
|
|
|
|
Truncated fixture — no sections, no compare link.
|
|
EOF
|
|
}
|
|
|
|
# Exactly <count> copies of <string> and no newline; awk passes the bytes of a
|
|
# multi-byte <string> through untouched.
|
|
repeat() {
|
|
awk -v s="$1" -v n="$2" 'BEGIN { for (i = 0; i < n; i++) printf "%s", s }'
|
|
}
|
|
|
|
# node runs the workflows' github-script, so it is required, not optional: a
|
|
# test that passes without it has run nothing. The unit lane's runner has it,
|
|
# which hack/pr-labeler-contract.bats already depends on.
|
|
need_node() {
|
|
command -v node >/dev/null 2>&1 && return 0
|
|
echo "node is required to run the workflows' github-script steps" >&2
|
|
return 1
|
|
}
|
|
|
|
# Runs one github-script step under node with its script exactly as the
|
|
# workflow carries it, from <tree> as the working directory; github and core
|
|
# are stand-ins. Leaves the step's log in <out>/log, one line per call to GitHub
|
|
# or core in <out>/calls (ending in `ok`, or `threw` if the step threw), and
|
|
# each body sent with updateRelease in <out>/body.<release_id>.
|
|
#
|
|
# run_step <workflow> <job> <step> <tree> <releases.json> <out> [<tag>]
|
|
run_step() {
|
|
mkdir -p "$6"
|
|
JOB="$2" STEP="$3" yq '.jobs[strenv(JOB)].steps[] | select(.name == strenv(STEP)) | .with.script' "$1" > "$6/script.js"
|
|
[ -s "$6/script.js" ] || { echo "no script for step '$3' in $1" >&2; return 1; }
|
|
cat > "$6/harness.js" <<'JS'
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const [scriptFile, releasesFile, outDir] = process.argv.slice(2);
|
|
let script = fs.readFileSync(scriptFile, 'utf8');
|
|
if (process.env.TAG) script = script.split('${{ steps.get_tag.outputs.tag }}').join(process.env.TAG);
|
|
if (script.includes('${{')) throw new Error('an expression in the script was left unsubstituted');
|
|
const releases = JSON.parse(fs.readFileSync(releasesFile, 'utf8'));
|
|
const calls = [];
|
|
const github = { rest: { repos: {
|
|
listReleases: async () => ({ data: releases }),
|
|
updateRelease: async (p) => {
|
|
const keys = Object.keys(p).filter((k) => !['owner', 'repo', 'release_id', 'body'].includes(k)).sort();
|
|
calls.push(['updateRelease', p.release_id, ...keys.map((k) => `${k}=${p[k]}`), ...('body' in p ? ['body'] : [])].join(' '));
|
|
if ('body' in p) fs.writeFileSync(path.join(outDir, `body.${p.release_id}`), p.body);
|
|
return { data: { ...releases.find((r) => r.id === p.release_id), ...p } };
|
|
},
|
|
} } };
|
|
const context = { repo: { owner: 'cozystack', repo: 'cozystack' } };
|
|
const core = {
|
|
setOutput: (k, v) => calls.push(`setOutput ${k}=${v}`),
|
|
setFailed: (m) => calls.push(`setFailed ${m}`),
|
|
};
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
new AsyncFunction('require', 'github', 'context', 'core', script)(require, github, context, core)
|
|
.then(() => 'ok', (e) => `threw ${e.message}`)
|
|
.then((result) => fs.writeFileSync(path.join(outDir, 'calls'), calls.concat(result).join('\n') + '\n'));
|
|
JS
|
|
( cd "$4" && TAG="${7:-}" node "$6/harness.js" "$6/script.js" "$5" "$6" > "$6/log" 2>&1 ) || {
|
|
echo "node failed for step '$3':" >&2; sed 's/^/ /' "$6/log" >&2; return 1; }
|
|
[ -s "$6/calls" ] || { echo "step '$3' recorded nothing" >&2; return 1; }
|
|
}
|
|
|
|
# The body finalize publishes for v1.7.0 in place of one over 100000 bytes.
|
|
LINK_V170='The release notes for v1.7.0 are too long for a GitHub release description; the full changelog is [docs/changelogs/v1.7.0.md](https://github.com/cozystack/cozystack/blob/v1.7.0/docs/changelogs/v1.7.0.md).'
|
|
|
|
# The rc-tag parser is shared by changelog-rc.yaml and mirrored inline by the
|
|
# reusable changelog-generate.yaml. It must REJECT whitespace outright, not strip
|
|
# it: a stripped 'v1. 6.0-rc.2' silently becomes a different tag, and a caller
|
|
# keying its concurrency lane on the raw input would race two spellings of the
|
|
# same tag to push the same staging branch. Execute it on the adversarial inputs
|
|
# from the review directly.
|
|
@test "parse-rc-tag: accepts a canonical rc tag and rejects whitespace / malformed ones" {
|
|
[ -x "$PARSE_RC" ] || { echo "hack/parse-rc-tag.sh missing or not executable" >&2; exit 1; }
|
|
|
|
# Canonical tag: exactly the three reconstructed key=value lines.
|
|
out="$("$PARSE_RC" v1.6.0-rc.2)" || { echo "parse-rc-tag rejected a valid rc tag" >&2; exit 1; }
|
|
want="rc_tag=v1.6.0-rc.2
|
|
stable_version=1.6.0
|
|
rc_branch=release-1.6.0-rc.2"
|
|
[ "$out" = "$want" ] || {
|
|
echo "parse-rc-tag output is wrong for v1.6.0-rc.2." >&2
|
|
echo "want:" >&2; printf '%s\n' "$want" | sed 's/^/ /' >&2
|
|
echo "got:" >&2; printf '%s\n' "$out" | sed 's/^/ /' >&2
|
|
exit 1
|
|
}
|
|
|
|
# Every adversarial input must be REJECTED (exit non-zero) AND write nothing to
|
|
# stdout, so a caller's `>> "$GITHUB_OUTPUT"` never gets a stray line from a
|
|
# partially-accepted parse.
|
|
tab="$(printf '\t')"
|
|
for bad in \
|
|
'v1. 6.0-rc.2' \
|
|
' v1.6.0-rc.2' \
|
|
'v1.6.0-rc.2 ' \
|
|
"v1.6.0-rc.2${tab}" \
|
|
'v1.6.0' \
|
|
'v1.6.0-rc' \
|
|
'1.6.0-rc.2' \
|
|
'v1.6.0-rc.2x' \
|
|
''; do
|
|
if o="$("$PARSE_RC" "$bad" 2>/dev/null)"; then
|
|
echo "parse-rc-tag ACCEPTED an invalid input: '$bad'" >&2
|
|
exit 1
|
|
fi
|
|
[ -z "$o" ] || {
|
|
echo "parse-rc-tag rejected '$bad' but still wrote to stdout: '$o'" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
}
|
|
|
|
@test "preserve: rescues a hand-written changelog from an existing staging branch" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
# A maintainer commits a changelog onto release-1.6.0 — the documented recovery
|
|
# when generation fails.
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.6.0
|
|
write_changelog docs/changelogs/v1.6.0.md 1.6.0
|
|
echo "HAND-WRITTEN MARKER" >> docs/changelogs/v1.6.0.md
|
|
git add -A && git commit -qm "hand-written changelog"
|
|
git push -q origin HEAD:refs/heads/release-1.6.0
|
|
git checkout -q main
|
|
rm -f docs/changelogs/v1.6.0.md
|
|
)
|
|
|
|
out="$tmp/preserved.md"
|
|
( cd "$tmp/work" && "$PRESERVE" release-1.6.0 1.6.0 "$out" ) || {
|
|
echo "preserve reported nothing to preserve, but the branch has a changelog" >&2
|
|
exit 1
|
|
}
|
|
[ -s "$out" ] || { echo "preserve exited 0 but wrote no file" >&2; exit 1; }
|
|
grep -q 'HAND-WRITTEN MARKER' "$out" || {
|
|
echo "preserved file is not the maintainer's version" >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "preserve: survives promote's full force-push rebuild cycle" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.6.0
|
|
write_changelog docs/changelogs/v1.6.0.md 1.6.0
|
|
echo "HAND-WRITTEN MARKER" >> docs/changelogs/v1.6.0.md
|
|
git add -A && git commit -qm "hand-written changelog"
|
|
git push -q origin HEAD:refs/heads/release-1.6.0
|
|
git checkout -q main
|
|
rm -f docs/changelogs/v1.6.0.md
|
|
)
|
|
|
|
# Replay the exact sequence from `Prepare stable branch`: preserve, reset the
|
|
# branch onto the rc tree (-B), restore, commit, force-push.
|
|
(
|
|
cd "$tmp/work"
|
|
PRESERVED="$(mktemp)"
|
|
if "$PRESERVE" release-1.6.0 1.6.0 "$PRESERVED"; then :; else PRESERVED=""; fi
|
|
git checkout -qB release-1.6.0 main
|
|
if [ -n "$PRESERVED" ]; then
|
|
mkdir -p docs/changelogs
|
|
cp "$PRESERVED" docs/changelogs/v1.6.0.md
|
|
fi
|
|
git add -A
|
|
git commit -qm "Prepare release v1.6.0" || true
|
|
git push -qf origin HEAD:refs/heads/release-1.6.0
|
|
)
|
|
|
|
# The maintainer's file must still be on the rebuilt remote branch.
|
|
content="$( cd "$tmp/work" && git fetch -q origin release-1.6.0 && git show FETCH_HEAD:docs/changelogs/v1.6.0.md )"
|
|
printf '%s\n' "$content" | grep -q 'HAND-WRITTEN MARKER' || {
|
|
echo "The force-push rebuild destroyed the hand-written changelog — the exact" >&2
|
|
echo "regression the preserve step exists to prevent." >&2
|
|
exit 1
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "preserve: reports nothing to preserve for absent branch, absent file, empty file" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
# No such branch — the ordinary first-promotion case.
|
|
( cd "$tmp/work" && "$PRESERVE" release-9.9.9 9.9.9 "$tmp/a.md" ) && {
|
|
echo "preserve claimed success for a branch that does not exist" >&2; exit 1; }
|
|
[ ! -f "$tmp/a.md" ] || { echo "preserve wrote a file for a missing branch" >&2; exit 1; }
|
|
|
|
# Branch exists, carries no changelog.
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.7.0
|
|
echo x > other.txt && git add -A && git commit -qm "no changelog"
|
|
git push -q origin HEAD:refs/heads/release-1.7.0
|
|
git checkout -q main
|
|
)
|
|
( cd "$tmp/work" && "$PRESERVE" release-1.7.0 1.7.0 "$tmp/b.md" ) && {
|
|
echo "preserve claimed success for a branch with no changelog" >&2; exit 1; }
|
|
|
|
# Branch exists, changelog is empty — must NOT be preserved, or it would
|
|
# suppress generation while being useless as release notes.
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -q release-1.7.0
|
|
mkdir -p docs/changelogs && : > docs/changelogs/v1.7.0.md
|
|
git add -A && git commit -qm "empty changelog"
|
|
git push -qf origin HEAD:refs/heads/release-1.7.0
|
|
git checkout -q main
|
|
)
|
|
( cd "$tmp/work" && "$PRESERVE" release-1.7.0 1.7.0 "$tmp/c.md" ) && {
|
|
echo "preserve carried over an EMPTY changelog; it would suppress generation" >&2
|
|
echo "and then publish as blank release notes." >&2
|
|
exit 1
|
|
}
|
|
[ ! -f "$tmp/c.md" ] || { echo "preserve left an empty artefact behind" >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
return 0
|
|
}
|
|
|
|
@test "pickup: changelog-preserve.sh fetches an rc-time changelog from the rc staging branch" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
# changelog-rc.yaml committed the changelog onto the rc staging branch
|
|
# release-1.6.0-rc.2 at rc time (docs/changelogs/vX.Y.Z.md — the stable name).
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.6.0-rc.2
|
|
write_changelog docs/changelogs/v1.6.0.md 1.6.0
|
|
echo "RC-TIME MARKER" >> docs/changelogs/v1.6.0.md
|
|
git add -A && git commit -qm "docs: add changelog for v1.6.0"
|
|
git push -q origin HEAD:refs/heads/release-1.6.0-rc.2
|
|
git checkout -q main
|
|
rm -f docs/changelogs/v1.6.0.md
|
|
)
|
|
|
|
# The generation core (changelog-generate.yaml) does exactly this to COPY the
|
|
# rc-time changelog into the deliverable path instead of regenerating it — it
|
|
# runs `mkdir -p docs/changelogs` first (git prunes the emptied dir on the
|
|
# `checkout main` above), so mirror that.
|
|
out="$tmp/work/docs/changelogs/v1.6.0.md"
|
|
( cd "$tmp/work" && mkdir -p docs/changelogs && "$PRESERVE" release-1.6.0-rc.2 1.6.0 docs/changelogs/v1.6.0.md ) || {
|
|
echo "pickup reported nothing to fetch, but the rc staging branch has a changelog" >&2
|
|
exit 1
|
|
}
|
|
[ -s "$out" ] || { echo "pickup exited 0 but wrote no file" >&2; exit 1; }
|
|
grep -q 'RC-TIME MARKER' "$out" || {
|
|
echo "picked-up file is not the rc-time changelog" >&2; exit 1; }
|
|
# And it must be publishable — the generation core validates it next.
|
|
"$VALIDATE" "$out" 1.6.0 >/dev/null 2>&1 || {
|
|
echo "picked-up changelog does not pass the validator" >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# hack/select-changelog-source.sh is the validate-then-fall-through the reusable
|
|
# core delegates to. Execute it for real against the three cases that matter, so a
|
|
# regression (e.g. dropping a validation, or letting an invalid source win) is
|
|
# caught by outcome rather than by a grep that a dead block could satisfy.
|
|
@test "select-source: a valid rc-tag-tree changelog wins over the staging branch" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
# A DIFFERENT valid changelog on the rc staging branch, to prove the tag tree wins.
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.6.0-rc.2
|
|
write_changelog docs/changelogs/v1.6.0.md 1.6.0
|
|
echo "BRANCH MARKER" >> docs/changelogs/v1.6.0.md
|
|
git add -A && git commit -qm "branch changelog"
|
|
git push -q origin HEAD:refs/heads/release-1.6.0-rc.2
|
|
git checkout -q main
|
|
rm -f docs/changelogs/v1.6.0.md
|
|
)
|
|
# A valid changelog in the working tree (the rc tag tree).
|
|
( cd "$tmp/work" && write_changelog docs/changelogs/v1.6.0.md 1.6.0 && echo "TAG-TREE MARKER" >> docs/changelogs/v1.6.0.md )
|
|
|
|
out="$( cd "$tmp/work" && "$SELECT" 1.6.0 release-1.6.0-rc.2 )" || { echo "select-source failed" >&2; exit 1; }
|
|
src="$(printf '%s\n' "$out" | sed -n 's/^source=//p' | tail -n1)"
|
|
[ "$src" = rc-tag ] || {
|
|
echo "expected source=rc-tag; got '$src'. Full output:" >&2; printf '%s\n' "$out" | sed 's/^/ /' >&2; exit 1; }
|
|
grep -q 'TAG-TREE MARKER' "$tmp/work/docs/changelogs/v1.6.0.md" || {
|
|
echo "the rc-tag-tree changelog was not the one selected" >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "select-source: an invalid rc-tag-tree changelog falls through to a valid staging branch" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
# A VALID changelog on the rc staging branch.
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.6.0-rc.2
|
|
write_changelog docs/changelogs/v1.6.0.md 1.6.0
|
|
echo "BRANCH MARKER" >> docs/changelogs/v1.6.0.md
|
|
git add -A && git commit -qm "branch changelog"
|
|
git push -q origin HEAD:refs/heads/release-1.6.0-rc.2
|
|
git checkout -q main
|
|
rm -f docs/changelogs/v1.6.0.md
|
|
)
|
|
# An INVALID changelog in the working tree — must be discarded, not win.
|
|
( cd "$tmp/work" && write_invalid_changelog docs/changelogs/v1.6.0.md 1.6.0 )
|
|
|
|
out="$( cd "$tmp/work" && "$SELECT" 1.6.0 release-1.6.0-rc.2 )" || { echo "select-source failed" >&2; exit 1; }
|
|
src="$(printf '%s\n' "$out" | sed -n 's/^source=//p' | tail -n1)"
|
|
[ "$src" = rc-branch ] || {
|
|
echo "expected source=rc-branch (invalid tag tree must fall through); got '$src'." >&2
|
|
printf '%s\n' "$out" | sed 's/^/ /' >&2; exit 1; }
|
|
printf '%s\n' "$out" | grep -q '::warning::' || {
|
|
echo "no loud ::warning:: for the discarded invalid tag-tree changelog" >&2; exit 1; }
|
|
grep -q 'BRANCH MARKER' "$tmp/work/docs/changelogs/v1.6.0.md" || {
|
|
echo "the valid rc-staging-branch changelog was not copied into place" >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "select-source: invalid in both the tag tree and the staging branch falls through to generate" {
|
|
tmp="$(mktemp -d)"
|
|
make_fixture "$tmp"
|
|
|
|
# An INVALID changelog on the rc staging branch.
|
|
(
|
|
cd "$tmp/work"
|
|
git checkout -qb release-1.6.0-rc.2
|
|
write_invalid_changelog docs/changelogs/v1.6.0.md 1.6.0
|
|
git add -A && git commit -qm "invalid branch changelog"
|
|
git push -q origin HEAD:refs/heads/release-1.6.0-rc.2
|
|
git checkout -q main
|
|
rm -f docs/changelogs/v1.6.0.md
|
|
)
|
|
# An INVALID changelog in the working tree too.
|
|
( cd "$tmp/work" && write_invalid_changelog docs/changelogs/v1.6.0.md 1.6.0 )
|
|
|
|
out="$( cd "$tmp/work" && "$SELECT" 1.6.0 release-1.6.0-rc.2 )" || { echo "select-source failed" >&2; exit 1; }
|
|
src="$(printf '%s\n' "$out" | sed -n 's/^source=//p' | tail -n1)"
|
|
[ "$src" = none ] || {
|
|
echo "expected source=none (both invalid -> generate); got '$src'." >&2
|
|
printf '%s\n' "$out" | sed 's/^/ /' >&2; exit 1; }
|
|
# Both discards must be reported loudly.
|
|
[ "$(printf '%s\n' "$out" | grep -c '::warning::')" -eq 2 ] || {
|
|
echo "expected exactly two ::warning:: lines (one per discarded invalid source)." >&2
|
|
printf '%s\n' "$out" | sed 's/^/ /' >&2; exit 1; }
|
|
# The invalid file must not be left behind to poison a later step.
|
|
[ ! -f "$tmp/work/docs/changelogs/v1.6.0.md" ] || {
|
|
echo "an invalid changelog was left on disk instead of being discarded" >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# promote-rc.yaml's rc-tag parse is github-script (JS), so mirror its exact policy
|
|
# under node and run the adversarial inputs from the review through it: whitespace
|
|
# is REJECTED, never trimmed-and-accepted. The greps keep the mirror honest.
|
|
@test "promote-rc parse: rejects whitespace-bearing rc tags (mirrors the workflow's github-script)" {
|
|
command -v node >/dev/null || { echo "node unavailable; skipping"; return 0; }
|
|
tmp="$(mktemp -d)"
|
|
|
|
cat > "$tmp/parse.js" <<'JS'
|
|
function parse(rc) {
|
|
if (/\s/.test(rc)) return 'reject:whitespace';
|
|
const m = rc.match(/^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$/);
|
|
if (!m) return 'reject:format';
|
|
return 'accept:' + m[1] + '.' + m[2] + '.' + m[3];
|
|
}
|
|
const lines = process.argv.slice(2).map((a) => a + '=' + parse(a));
|
|
require('fs').writeFileSync(1, lines.join('\n') + '\n');
|
|
JS
|
|
|
|
tab="$(printf '\t')"
|
|
node "$tmp/parse.js" \
|
|
'v1.6.0-rc.2' \
|
|
' v1.6.0-rc.2' \
|
|
'v1.6.0-rc.2 ' \
|
|
"v1.6.0-rc.2${tab}" \
|
|
'v1. 6.0-rc.2' \
|
|
'v1.6.0' > "$tmp/out.txt" 2>"$tmp/err.txt" || { echo "node failed:" >&2; cat "$tmp/err.txt" >&2; exit 1; }
|
|
|
|
want="v1.6.0-rc.2=accept:1.6.0
|
|
v1.6.0-rc.2=reject:whitespace
|
|
v1.6.0-rc.2 =reject:whitespace
|
|
v1.6.0-rc.2${tab}=reject:whitespace
|
|
v1. 6.0-rc.2=reject:whitespace
|
|
v1.6.0=reject:format"
|
|
got="$(cat "$tmp/out.txt")"
|
|
[ "$got" = "$want" ] || {
|
|
echo "promote parse policy mismatch." >&2
|
|
echo "want:" >&2; printf '%s\n' "$want" | sed 's/^/ /' >&2
|
|
echo "got:" >&2; printf '%s\n' "$got" | sed 's/^/ /' >&2
|
|
exit 1
|
|
}
|
|
|
|
# The mirror must match what promote-rc.yaml actually does: reject whitespace,
|
|
# and NOT trim-and-accept. Scoped to the parse STEP, not the parse job: the job
|
|
# also holds the e2e gate's github-script, whose own string handling would
|
|
# otherwise trip a negative pin about tag policy.
|
|
parse_step="$(step_block 'Parse and validate rc tag' "$PROMOTE")"
|
|
[ -n "$parse_step" ] || { echo "promote-rc.yaml has no 'Parse and validate rc tag' step." >&2; exit 1; }
|
|
printf '%s\n' "$parse_step" | code_lines | grep -qF '/\s/.test(rc)' || {
|
|
echo "promote-rc.yaml's parse no longer rejects whitespace with /\\s/.test(rc)." >&2; exit 1; }
|
|
printf '%s\n' "$parse_step" | code_lines | grep -qF '.trim()' && {
|
|
echo "promote-rc.yaml's parse still trims the rc tag; policy diverges from the changelog job." >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
return 0
|
|
}
|
|
|
|
# finalize's Publish draft release step, run as the workflow carries it, one
|
|
# merge-commit tree per case. Whatever the tree holds, the release is
|
|
# published: with the release notes, else the changelog, else the draft's own
|
|
# body, and with a link in place of anything over 100000 bytes, since the tags
|
|
# already exist and a body GitHub refuses would strand the release.
|
|
@test "finalize: publishes the release notes, else the changelog, and a link over 100000 bytes" {
|
|
need_node
|
|
tmp="$(mktemp -d)"
|
|
cat > "$tmp/releases.json" <<'EOF'
|
|
[
|
|
{"id": 1, "draft": true, "prerelease": false, "tag_name": "v1.7.0", "name": "v1.7.0", "body": "Promoted from v1.7.0-rc.3."},
|
|
{"id": 2, "draft": false, "prerelease": false, "tag_name": "v1.6.5", "name": "v1.6.5", "body": "v1.6.5"}
|
|
]
|
|
EOF
|
|
notes=docs/releasenotes/v1.7.0.md
|
|
cl=docs/changelogs/v1.7.0.md
|
|
for c in notes blanknotes changelog neither blank at over multibyte overnotes; do
|
|
mkdir -p "$tmp/$c/docs/releasenotes" "$tmp/$c/docs/changelogs"
|
|
done
|
|
printf '# Cozystack v1.7.0\n\nThe highlights.\n' > "$tmp/notes/$notes"
|
|
printf ' \n\t\n' > "$tmp/blanknotes/$notes"
|
|
printf ' \n\n \n' > "$tmp/blank/$cl"
|
|
for c in notes blanknotes changelog; do write_changelog "$tmp/$c/$cl" 1.7.0; done
|
|
repeat a 100000 > "$tmp/at/$cl"
|
|
repeat a 100001 > "$tmp/over/$cl"
|
|
# 40000 characters, 120000 bytes: the limit is in bytes.
|
|
repeat '€' 40000 > "$tmp/multibyte/$cl"
|
|
repeat a 100001 > "$tmp/overnotes/$notes"
|
|
write_changelog "$tmp/overnotes/$cl" 1.7.0
|
|
|
|
for spec in notes:$notes blanknotes:$cl changelog:$cl at:$cl over:link multibyte:link overnotes:link neither:none blank:none; do
|
|
c="${spec%%:*}"; want="${spec#*:}"
|
|
run_step "$FINALIZE" finalize 'Publish draft release' "$tmp/$c" "$tmp/releases.json" "$tmp/out.$c" v1.7.0
|
|
with_body=' body'
|
|
[ "$want" != none ] || with_body=''
|
|
expected="$(printf 'ok\nsetOutput make_latest=true\nupdateRelease 1 draft=false make_latest=true prerelease=false%s\n' "$with_body")"
|
|
[ "$(sort "$tmp/out.$c/calls")" = "$expected" ] || {
|
|
echo "$c: finalize did something other than publishing v1.7.0:" >&2
|
|
sed 's/^/ /' "$tmp/out.$c/calls" "$tmp/out.$c/log" >&2; exit 1; }
|
|
case "$want" in
|
|
none)
|
|
grep -q '^::warning::' "$tmp/out.$c/log" || {
|
|
echo "$c: the draft's body was kept without a ::warning::" >&2; exit 1; } ;;
|
|
link)
|
|
[ "$(cat "$tmp/out.$c/body.1")" = "$LINK_V170" ] || {
|
|
echo "$c: the body over 100000 bytes was not replaced by the link:" >&2
|
|
head -c 300 "$tmp/out.$c/body.1" >&2; exit 1; }
|
|
grep -q '^::warning::.* bytes, over MAX_BODY_BYTES' "$tmp/out.$c/log" || {
|
|
echo "$c: the link was published without a ::warning::" >&2; exit 1; } ;;
|
|
*)
|
|
cmp -s "$tmp/out.$c/body.1" "$tmp/$c/$want" || {
|
|
echo "$c: the body is not $want byte for byte" >&2; exit 1; }
|
|
grep -qF "Release body from $want ($(wc -c < "$tmp/$c/$want") bytes)" "$tmp/out.$c/log" || {
|
|
echo "$c: the log does not give the body's source and byte length:" >&2
|
|
sed 's/^/ /' "$tmp/out.$c/log" >&2; exit 1; } ;;
|
|
esac
|
|
done
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# A release with no release notes publishes exactly what finalize published
|
|
# before they existed. Every shipped changelog is a fixture; one over 100000
|
|
# bytes, which GitHub may refuse, publishes the link instead.
|
|
@test "finalize: without release notes a shipped changelog is published byte for byte, or as the link over 100000 bytes" {
|
|
need_node
|
|
tmp="$(mktemp -d)"
|
|
checked=0
|
|
verbatim=0
|
|
for f in "$REPO_ROOT"/docs/changelogs/v*.md; do
|
|
[ -f "$f" ] || continue
|
|
tag="$(basename "$f" .md)"
|
|
mkdir -p "$tmp/$tag/docs/changelogs"
|
|
cp "$f" "$tmp/$tag/docs/changelogs/"
|
|
printf '[{"id": 1, "draft": true, "prerelease": false, "tag_name": "%s", "name": "%s", "body": "draft"}]\n' "$tag" "$tag" > "$tmp/$tag.json"
|
|
run_step "$FINALIZE" finalize 'Publish draft release' "$tmp/$tag" "$tmp/$tag.json" "$tmp/out.$tag" "$tag"
|
|
if [ "$(wc -c < "$f")" -le 100000 ]; then
|
|
cmp -s "$tmp/out.$tag/body.1" "$f" || { echo "$tag: not published byte for byte" >&2; exit 1; }
|
|
verbatim=$((verbatim + 1))
|
|
else
|
|
grep -qF "/blob/$tag/docs/changelogs/$tag.md)." "$tmp/out.$tag/body.1" || {
|
|
echo "$tag: over 100000 bytes but not published as the link" >&2; exit 1; }
|
|
fi
|
|
checked=$((checked + 1))
|
|
done
|
|
[ "$checked" -ge 10 ] && [ "$verbatim" -ge 10 ] || {
|
|
echo "$checked changelog(s) checked, $verbatim verbatim; the glob broke and this verifies nothing." >&2; exit 1; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# update-releasenotes' sync step, run as the workflow carries it over one main
|
|
# checkout. It makes finalize's choice of file, but a body over 100000 bytes is
|
|
# never synced, not even as the link: the tags.yaml backstop ports a changelog
|
|
# to main without its release notes, and it would replace the body finalize
|
|
# published.
|
|
@test "update-releasenotes: syncs the release notes, else the changelog, and never a body over 100000 bytes" {
|
|
need_node
|
|
tmp="$(mktemp -d)"
|
|
m="$tmp/main"
|
|
mkdir -p "$m/docs/releasenotes" "$m/docs/changelogs"
|
|
printf '# v1.0.1\n\nThe highlights.\n' > "$m/docs/releasenotes/v1.0.1.md"
|
|
printf ' \n' > "$m/docs/releasenotes/v1.0.2.md"
|
|
for v in 1 2 3 8 9; do write_changelog "$m/docs/changelogs/v1.0.$v.md" "1.0.$v"; done
|
|
repeat a 100001 > "$m/docs/changelogs/v1.0.5.md"
|
|
repeat '€' 40000 > "$m/docs/changelogs/v1.0.6.md"
|
|
repeat a 100000 > "$m/docs/changelogs/v1.0.7.md"
|
|
repeat a 100001 > "$m/docs/releasenotes/v1.0.9.md"
|
|
UP_TO_DATE="$(cat "$m/docs/changelogs/v1.0.8.md")" node -e '
|
|
const r = (v, body) => ({ id: v, draft: false, prerelease: false, tag_name: `v1.0.${v}`, name: `v1.0.${v}`, body });
|
|
process.stdout.write(JSON.stringify([1, 2, 3, 4, 5, 6, 7, 9].map((v) => r(v, "old body")).concat(r(8, process.env.UP_TO_DATE))));' > "$tmp/releases.json"
|
|
|
|
run_step "$SYNC" update-releasenotes 'Update release notes from changelogs' "$m" "$tmp/releases.json" "$tmp/out"
|
|
want="updateRelease 1 body
|
|
updateRelease 2 body
|
|
updateRelease 3 body
|
|
updateRelease 7 body
|
|
ok"
|
|
[ "$(cat "$tmp/out/calls")" = "$want" ] || {
|
|
echo "The sync updated other releases than v1.0.1, v1.0.2, v1.0.3 and v1.0.7:" >&2
|
|
sed 's/^/ /' "$tmp/out/calls" "$tmp/out/log" >&2; exit 1; }
|
|
for pair in 1:releasenotes 2:changelogs 3:changelogs 7:changelogs; do
|
|
v="${pair%%:*}"
|
|
cmp -s "$tmp/out/body.$v" "$m/docs/${pair#*:}/v1.0.$v.md" || {
|
|
echo "v1.0.$v was not synced with docs/${pair#*:}/v1.0.$v.md" >&2; exit 1; }
|
|
done
|
|
for v in 5 6 9; do
|
|
grep -q "^::warning::.*over MAX_BODY_BYTES.*leaving the body of release v1.0.$v as it is" "$tmp/out/log" || {
|
|
echo "v1.0.$v, over 100000 bytes, was left alone without a ::warning:: naming it" >&2; exit 1; }
|
|
done
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# The one assumption no amount of YAML reading can settle: that GitHub really
|
|
# runs a job whose `needs` includes a FAILED job, when its `if:` gates only on a
|
|
# different, successful one. The entire non-blocking design rests on it. `act`
|
|
# executes the fixture workflow for real and the assertions below read its output.
|
|
#
|
|
# Docker-gated: no Docker, no verdict — and the test says so rather than passing
|
|
# quietly, because a silent skip here is indistinguishable from a green result on
|
|
# the one thing that most needs proving.
|
|
@test "act: open-pr runs when the changelog job fails, and failed-job outputs are empty" {
|
|
if ! command -v act >/dev/null || ! docker info >/dev/null 2>&1; then
|
|
echo "act or Docker unavailable — GitHub needs/if semantics NOT verified in this run." >&2
|
|
return 0
|
|
fi
|
|
|
|
fixture="$REPO_ROOT/hack/testdata/needs-semantics.yaml"
|
|
[ -f "$fixture" ] || { echo "missing fixture $fixture" >&2; exit 1; }
|
|
|
|
image="debian:stable-slim"
|
|
docker image inspect "$image" >/dev/null 2>&1 || {
|
|
echo "$image not present locally and pulling is disabled — semantics NOT verified." >&2
|
|
return 0
|
|
}
|
|
|
|
out="$(cd "$REPO_ROOT" && act push -W "$fixture" -P "ubuntu-latest=$image" --pull=false 2>&1 || true)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'OPEN_PR_RAN=yes' || {
|
|
echo "open-pr did NOT run after the changelog job failed. The non-blocking" >&2
|
|
echo "guarantee does not hold: a Copilot or npm outage would leave a pushed" >&2
|
|
echo "staging branch and a drafted release with no promote PR." >&2
|
|
printf '%s\n' "$out" | tail -40 >&2
|
|
exit 1
|
|
}
|
|
|
|
printf '%s\n' "$out" | grep -q 'changelog_result=failure' || {
|
|
echo "The fixture's changelog job did not actually fail; the test proved nothing." >&2
|
|
exit 1
|
|
}
|
|
|
|
# Outputs of a failed job come back empty. This is WHY open-pr re-establishes
|
|
# changelog presence from disk instead of trusting needs.changelog.outputs.
|
|
printf '%s\n' "$out" | grep -q 'changelog_present=\[\]' || {
|
|
echo "A failed job's outputs were expected to be empty. If they now propagate," >&2
|
|
echo "the on-disk re-check in open-pr may be masking a simpler design." >&2
|
|
exit 1
|
|
}
|
|
|
|
# And a successful job's outputs must still reach the dependent job.
|
|
printf '%s\n' "$out" | grep -q 'stable_version=\[1.6.0\]' || {
|
|
echo "promote's outputs did not reach open-pr; open-pr could not target the" >&2
|
|
echo "right branch or version." >&2
|
|
exit 1
|
|
}
|
|
}
|