From 461c25b08e6cf8cd23f9b6250f521b281058600d Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:47:55 +0000 Subject: [PATCH 1/6] wip: add unreleased marker guard --- .github/scripts/marker-check.sh | 112 ++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100755 .github/scripts/marker-check.sh diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh new file mode 100755 index 0000000..65c1be6 --- /dev/null +++ b/.github/scripts/marker-check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Availability-marker guard (issue #238). Five of five markers found by #221 +# outlived the releases that shipped their machinery. A release candidate must +# therefore reject a marker its assembled changelog makes false, while every +# tree rejects an untraceable marker. Cross-repo citations are traceable but +# are not compared with this repository's changelog. +# +# Usage: marker-check.sh [tree-dir] (default: the repository root) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tree="${1:-$ROOT}" + +fail() { + printf '%s\n' "$@" >&2 + exit 1 +} + +if ! git -C "$tree" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + fail "marker-check: $tree is not a Git work tree; tracked Markdown cannot be determined." +fi + +marker_records="$(mktemp)" +trap 'rm -f "$marker_records"' EXIT + +mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md') +for relative in "${markdown_files[@]}"; do + case "$relative" in + changelog.d/*) continue ;; + esac + + if ! awk -v file="$relative" ' + { lines[NR] = $0 } + END { + token = "**unreleased**" + citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)" + bad = 0 + + for (line_no = 1; line_no <= NR; line_no++) { + remaining = lines[line_no] + offset = 0 + while ((at = index(remaining, token)) != 0) { + rest = substr(remaining, at + length(token)) + candidate = rest + next_line = line_no + 1 + while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) { + candidate = candidate " " lines[next_line] + next_line++ + } + + if (match(candidate, citation_re)) { + citation = substr(candidate, RSTART, RLENGTH) + sub(/^[[:space:]]*\(/, "", citation) + sub(/\)$/, "", citation) + printf "%s\t%d\t%s\n", file, line_no, citation + } else { + printf "marker-check: %s:%d: %s\n", file, line_no, lines[line_no] > "/dev/stderr" + printf "marker-check: every **unreleased** marker must be immediately followed by an issue citation such as (#238), (crew#293), or (owner/repo#293).\n" > "/dev/stderr" + bad = 1 + } + + offset += at + length(token) - 1 + remaining = substr(lines[line_no], offset + 1) + } + } + exit bad + } + ' "$tree/$relative" >>"$marker_records"; then + exit 1 + fi +done + +version="" +if [ -f "$tree/VERSION" ]; then + IFS= read -r version <"$tree/VERSION" || true +fi + +if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + [ -f "$tree/CHANGELOG.md" ] || \ + fail "marker-check: bare VERSION '$version' requires CHANGELOG.md for the release-marker check." + + shipped_issues="$(awk ' + $1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ { + if (in_section) exit + in_section = 1 + next + } + in_section && /^##[[:space:]]/ { exit } + in_section { + text = $0 + while (match(text, /(^|[^[:alnum:]_./-])#[0-9]+/)) { + issue = substr(text, RSTART, RLENGTH) + sub(/^.*#/, "", issue) + print issue + text = substr(text, RSTART + RLENGTH) + } + } + ' "$tree/CHANGELOG.md" | sort -u)" + + while IFS=$'\t' read -r file line citation; do + case "$citation" in + \#*) + issue="${citation#\#}" + if printf '%s\n' "$shipped_issues" | grep -qxF "$issue"; then + fail "marker-check: $file:$line: **unreleased** (#$issue) is false on release candidate $version; CHANGELOG.md's top release section cites #$issue, so clear the marker in this release PR." + fi + ;; + esac + done <"$marker_records" +fi + +echo "marker-check: availability markers agree with the tree." From dca8e7220c0e8a636c4acb363ef9d33211a96d97 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:49:22 +0000 Subject: [PATCH 2/6] test: exercise documentation marker guard --- .github/workflows/ci.yml | 4 ++ changelog.d/238.md | 4 ++ test/marker-check.test.sh | 94 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 changelog.d/238.md create mode 100644 test/marker-check.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a6882..c17c424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,10 @@ jobs: # The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails # CI here, not a consumer's release. run: bash .github/scripts/self-ref-check.sh + - name: Documentation availability markers + # Five stale markers survived the tags that shipped their machinery + # (#221); #238 makes the release candidate reject that drift. + run: bash .github/scripts/marker-check.sh - name: Tests env: # The npm-backed version_write case may skip locally when npm is diff --git a/changelog.d/238.md b/changelog.d/238.md new file mode 100644 index 0000000..6805c0b --- /dev/null +++ b/changelog.d/238.md @@ -0,0 +1,4 @@ +### Added + +- Guard documentation availability markers against missing issue citations + and release candidates that already ship the cited work (#238). diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh new file mode 100644 index 0000000..7017e84 --- /dev/null +++ b/test/marker-check.test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Contract tests for .github/scripts/marker-check.sh (issue #238). The guard +# is driven against tracked fixture trees; set -u, not -e, because failures +# are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +CHECK="$ROOT/.github/scripts/marker-check.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +fixture() { + local name="$1" version="$2" + mkdir -p "$TMP/$name/docs" "$TMP/$name/changelog.d" + git -C "$TMP/$name" init -q + printf '%s\n' "$version" >"$TMP/$name/VERSION" + printf '# Changelog\n\n## 0.5.0 — 2026-08-03\n\n- Shipped (#221).\n' \ + >"$TMP/$name/CHANGELOG.md" +} + +run_check() { + git -C "$TMP/$1" add . + bash "$CHECK" "$TMP/$1" +} + +fixture wrapped 0.6.0-dev +cat >"$TMP/wrapped/docs/CONSUMERS.md" <<'EOF' +The new guard remains **unreleased** +(#238) until the next tag. +EOF +check "a wrapped local citation is accepted" 0 "agree with the tree" \ + run_check wrapped + +fixture uncited 0.6.0-dev +printf 'The new guard remains **unreleased** for now.\n' \ + >"$TMP/uncited/docs/CONSUMERS.md" +check "an uncited marker fails on a dev tree with file and line" 1 \ + "docs/CONSUMERS.md:1" run_check uncited + +fixture shipped 0.6.0 +printf 'The new guard remains **unreleased** (#224).\n' \ + >"$TMP/shipped/docs/CONSUMERS.md" +cat >"$TMP/shipped/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.6.0 — 2026-08-03 + +- The guard shipped (#224). + +## 0.5.0 — 2026-08-02 + +- Older work (#999). +EOF +check "a release rejects a marker cited by its top section" 1 \ + "docs/CONSUMERS.md:1: **unreleased** (#224)" run_check shipped + +fixture not-shipped 0.6.0 +printf 'Future work remains **unreleased** (#999).\n' \ + >"$TMP/not-shipped/docs/CONSUMERS.md" +cp "$TMP/shipped/CHANGELOG.md" "$TMP/not-shipped/CHANGELOG.md" +check "a release keeps a marker absent from its top section" 0 \ + "agree with the tree" run_check not-shipped + +fixture dev-shipped 0.6.0-dev +printf 'Future work remains **unreleased** (#224).\n' \ + >"$TMP/dev-shipped/docs/CONSUMERS.md" +cp "$TMP/shipped/CHANGELOG.md" "$TMP/dev-shipped/CHANGELOG.md" +check "a dev tree does not compare markers with shipped sections" 0 \ + "agree with the tree" run_check dev-shipped + +fixture cross-repo 0.6.0 +printf 'Crew work remains **unreleased** (crew#293).\n' \ + >"$TMP/cross-repo/docs/CONSUMERS.md" +cat >"$TMP/cross-repo/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.6.0 — 2026-08-03 + +- Local work shipped (#293). +EOF +check "a cross-repo citation is valid and ignored by release comparison" 0 \ + "agree with the tree" run_check cross-repo + +fixture exclusions 0.6.0-dev +printf '# Notes\n\n## Unreleased\n' >"$TMP/exclusions/NOTES.md" +printf -- '- A fragment may say **unreleased** without being documentation.\n' \ + >"$TMP/exclusions/changelog.d/999.md" +check "headings and changelog fragments do not trip the guard" 0 \ + "agree with the tree" run_check exclusions + +summary From 7200c8da49f8b00a25c458fad2b42bd17d4fa9d5 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:51:17 +0000 Subject: [PATCH 3/6] docs: define traceable availability markers --- docs/CONSUMERS.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index fc12b4c..38c9fc0 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -113,12 +113,17 @@ the machinery at all: self-hosted runner still wants it: the guard's value is the day somebody adds one. - This guide documents `main`. New machinery is marked **unreleased** - here until a release tag ships it — and the release PR that ships the - machinery clears, in that same PR, every marker its own assembled - section makes false: the section cites its issues, each marker cites - the same issue, and the release PR's diff is the one place both - halves are visible at once (#221). If an action does not exist at the + This guide documents `main`. New machinery is marked with the lowercase + word `unreleased` in bold, immediately followed by its issue citation + (for example, `(#238)`); whitespace between them may include a line break. + A citation is mandatory, because a marker the guard cannot trace is a + marker it cannot prove false. Cross-repo citations such as `(crew#293)` + satisfy that traceability rule but are not compared with this repository's + release section. The ceremony-only `marker-check.sh` guard enforces both + rules. The release PR that ships the machinery clears, in that same PR, + every marker its own assembled section makes false: the section cites its + issues, each marker cites the same issue, and the release PR's diff is the + one place both halves are visible at once (#221). If an action does not exist at the consumer's pinned tag, adopt it with the pin bump to the first tag that carries it; never mix a moving or newer ref into an otherwise exact-pin consumer. In particular, `0.1.0` carries `changelog-armed`, From d87d76d64bcb913581f686dc0df5f46c17310d7f Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:56:02 +0000 Subject: [PATCH 4/6] fix: align marker guard with release oracle --- .github/scripts/marker-check.sh | 26 ++++++++++++++++++++------ docs/CONSUMERS.md | 20 ++++++++++---------- test/marker-check.test.sh | 30 ++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh index 65c1be6..b973f8f 100755 --- a/.github/scripts/marker-check.sh +++ b/.github/scripts/marker-check.sh @@ -3,7 +3,10 @@ # outlived the releases that shipped their machinery. A release candidate must # therefore reject a marker its assembled changelog makes false, while every # tree rejects an untraceable marker. Cross-repo citations are traceable but -# are not compared with this repository's changelog. +# are not compared with this repository's changelog; a marker for this repo's +# own issue uses bare #N, never a self-qualified repository citation (#238 D8). +# CHANGELOG.md is the release oracle and immutable shipped prose, so it and the +# fragments that feed it are excluded from the documentation scan (#238 D5). # # Usage: marker-check.sh [tree-dir] (default: the repository root) set -euo pipefail @@ -26,25 +29,36 @@ trap 'rm -f "$marker_records"' EXIT mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md') for relative in "${markdown_files[@]}"; do case "$relative" in - changelog.d/*) continue ;; + CHANGELOG.md|changelog.d/*) continue ;; esac if ! awk -v file="$relative" ' - { lines[NR] = $0 } + function without_inline_code(text, before, after) { + while (match(text, /`[^`]*`/)) { + before = substr(text, 1, RSTART - 1) + after = substr(text, RSTART + RLENGTH) + text = before after + } + return text + } + { + lines[NR] = $0 + scan_lines[NR] = without_inline_code($0) + } END { token = "**unreleased**" citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)" bad = 0 for (line_no = 1; line_no <= NR; line_no++) { - remaining = lines[line_no] + remaining = scan_lines[line_no] offset = 0 while ((at = index(remaining, token)) != 0) { rest = substr(remaining, at + length(token)) candidate = rest next_line = line_no + 1 while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) { - candidate = candidate " " lines[next_line] + candidate = candidate " " scan_lines[next_line] next_line++ } @@ -60,7 +74,7 @@ for relative in "${markdown_files[@]}"; do } offset += at + length(token) - 1 - remaining = substr(lines[line_no], offset + 1) + remaining = substr(scan_lines[line_no], offset + 1) } } exit bad diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 38c9fc0..cb3f30b 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -113,14 +113,14 @@ the machinery at all: self-hosted runner still wants it: the guard's value is the day somebody adds one. - This guide documents `main`. New machinery is marked with the lowercase - word `unreleased` in bold, immediately followed by its issue citation - (for example, `(#238)`); whitespace between them may include a line break. - A citation is mandatory, because a marker the guard cannot trace is a - marker it cannot prove false. Cross-repo citations such as `(crew#293)` - satisfy that traceability rule but are not compared with this repository's - release section. The ceremony-only `marker-check.sh` guard enforces both - rules. The release PR that ships the machinery clears, in that same PR, + This guide documents `main`. A marker is the literal token + `**unreleased**` immediately followed by its issue citation (for example, + `(#238)`); whitespace between them may include a line break. A citation is + mandatory, because a marker the guard cannot trace is a marker it cannot + prove false. A marker for this repository's own issue uses bare `#N`. + Cross-repo citations such as `(crew#293)` satisfy the traceability rule but + are not compared with this repository's release section. The ceremony-only + `marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR, every marker its own assembled section makes false: the section cites its issues, each marker cites the same issue, and the release PR's diff is the one place both halves are visible at once (#221). If an action does not exist at the @@ -153,7 +153,7 @@ the machinery at all: - uses: heavy-duty/ceremony/actions/refs-not-closing@ ``` - `refs-not-closing` is **unreleased** until the first tag carrying #218. + `refs-not-closing` is **unreleased** (#218) until the first tag carrying it. Adopt this caller with that ordinary pin bump; never point only this file at a moving or newer ref. 7. **Labels automation** (optional but recommended): the two callers from @@ -592,7 +592,7 @@ mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing. `RELEASES.md` joins that mirror with the first tag carrying ceremony#248. -It is **unreleased** until that tag exists: consumers add +It is **unreleased** (#248) until that tag exists: consumers add `.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by copying it ahead of their pinned doctrine set. diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh index 7017e84..ca373fd 100644 --- a/test/marker-check.test.sh +++ b/test/marker-check.test.sh @@ -84,11 +84,37 @@ EOF check "a cross-repo citation is valid and ignored by release comparison" 0 \ "agree with the tree" run_check cross-repo +fixture self-qualified 0.6.0 +printf 'Ceremony work remains **unreleased** (ceremony#248).\n' \ + >"$TMP/self-qualified/docs/CONSUMERS.md" +cat >"$TMP/self-qualified/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.6.0 — 2026-08-03 + +- Ceremony work shipped (#248). +EOF +check "a self-qualified citation is ignored; local markers must use bare #N" 0 \ + "agree with the tree" run_check self-qualified + fixture exclusions 0.6.0-dev -printf '# Notes\n\n## Unreleased\n' >"$TMP/exclusions/NOTES.md" +cat >"$TMP/exclusions/NOTES.md" <<'EOF' +# Notes + +## Unreleased + +The marker token is `**unreleased**`. +EOF printf -- '- A fragment may say **unreleased** without being documentation.\n' \ >"$TMP/exclusions/changelog.d/999.md" -check "headings and changelog fragments do not trip the guard" 0 \ +cat >"$TMP/exclusions/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.5.0 — 2026-08-03 + +- Shipped prose may discuss **unreleased** markers without becoming one. +EOF +check "headings, inline mentions, changelog entries, and fragments are excluded" 0 \ "agree with the tree" run_check exclusions summary From 2a1f8a012bea31fa77dca4a630a7a70a92413565 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:05:05 +0000 Subject: [PATCH 5/6] test: pin inline marker mention boundary --- .github/scripts/marker-check.sh | 2 ++ docs/CONSUMERS.md | 8 +++++--- test/marker-check.test.sh | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh index b973f8f..851ed6a 100755 --- a/.github/scripts/marker-check.sh +++ b/.github/scripts/marker-check.sh @@ -7,6 +7,8 @@ # own issue uses bare #N, never a self-qualified repository citation (#238 D8). # CHANGELOG.md is the release oracle and immutable shipped prose, so it and the # fragments that feed it are excluded from the documentation scan (#238 D5). +# A token inside inline code is a mention, not a marker; spans are stripped +# individually so unrelated backticks cannot hide a real marker (#238 D9). # # Usage: marker-check.sh [tree-dir] (default: the repository root) set -euo pipefail diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index cb3f30b..413385f 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -117,9 +117,11 @@ the machinery at all: `**unreleased**` immediately followed by its issue citation (for example, `(#238)`); whitespace between them may include a line break. A citation is mandatory, because a marker the guard cannot trace is a marker it cannot - prove false. A marker for this repository's own issue uses bare `#N`. - Cross-repo citations such as `(crew#293)` satisfy the traceability rule but - are not compared with this repository's release section. The ceremony-only + prove false. A token inside an inline-code span is a mention, not a marker; + spans are ignored individually, so unrelated inline code cannot hide one. + A marker for this repository's own issue uses bare `#N`. Cross-repo + citations such as `(crew#293)` satisfy the traceability rule but are not + compared with this repository's release section. The ceremony-only `marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR, every marker its own assembled section makes false: the section cites its issues, each marker cites the same issue, and the release PR's diff is the diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh index ca373fd..0310c8c 100644 --- a/test/marker-check.test.sh +++ b/test/marker-check.test.sh @@ -40,6 +40,26 @@ printf 'The new guard remains **unreleased** for now.\n' \ check "an uncited marker fails on a dev tree with file and line" 1 \ "docs/CONSUMERS.md:1" run_check uncited +fixture inline-mention 0.6.0-dev +cat >"$TMP/inline-mention/docs/CONSUMERS.md" <<'EOF' +The marker token is `**unreleased**`. +EOF +check "an inline-code token is a mention and needs no citation" 0 \ + "agree with the tree" run_check inline-mention + +fixture inline-bare 0.6.0-dev +printf 'The marker token is **unreleased**.\n' \ + >"$TMP/inline-bare/docs/CONSUMERS.md" +check "removing the backticks exposes the uncited marker" 1 \ + "docs/CONSUMERS.md:1" run_check inline-bare + +fixture inline-neighbor 0.6.0-dev +cat >"$TMP/inline-neighbor/docs/CONSUMERS.md" <<'EOF' +The `new guard` remains **unreleased** until its tag. +EOF +check "unrelated inline code cannot hide an uncited marker on the same line" 1 \ + "docs/CONSUMERS.md:1" run_check inline-neighbor + fixture shipped 0.6.0 printf 'The new guard remains **unreleased** (#224).\n' \ >"$TMP/shipped/docs/CONSUMERS.md" From e98020b4321ef02ee4dad8d8744755cfa7dc5c42 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:57:51 +0000 Subject: [PATCH 6/6] test: document marker fixture incidents --- test/marker-check.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh index 0310c8c..9df9b65 100644 --- a/test/marker-check.test.sh +++ b/test/marker-check.test.sh @@ -60,6 +60,7 @@ EOF check "unrelated inline code cannot hide an uncited marker on the same line" 1 \ "docs/CONSUMERS.md:1" run_check inline-neighbor +# This release comparison would have caught all five of #221's stale markers. fixture shipped 0.6.0 printf 'The new guard remains **unreleased** (#224).\n' \ >"$TMP/shipped/docs/CONSUMERS.md" @@ -117,6 +118,7 @@ EOF check "a self-qualified citation is ignored; local markers must use bare #N" 0 \ "agree with the tree" run_check self-qualified +# CHANGELOG.md:38 on main is the live bold-token entry prose this exclusion models. fixture exclusions 0.6.0-dev cat >"$TMP/exclusions/NOTES.md" <<'EOF' # Notes