Ctrl K
ComunidadGuías…

hack/release-changelog-behaviour.bats

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

Archivo original ↗Preguntar a KiBot
#!/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
  }
}