Merge pull request #273 from andriujoseba/build/238-marker-check

feat: guard unreleased documentation markers
This commit is contained in:
Daniel Marin 2026-08-04 11:14:01 +01:00 committed by GitHub
commit 05738ff0dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 293 additions and 8 deletions

128
.github/scripts/marker-check.sh vendored Executable file
View file

@ -0,0 +1,128 @@
#!/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; 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).
# 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
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.md|changelog.d/*) continue ;;
esac
if ! awk -v file="$relative" '
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 = 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 " " scan_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(scan_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."

View file

@ -31,6 +31,10 @@ jobs:
# The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails # The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails
# CI here, not a consumer's release. # CI here, not a consumer's release.
run: bash .github/scripts/self-ref-check.sh 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 - name: Tests
env: env:
# The npm-backed version_write case may skip locally when npm is # The npm-backed version_write case may skip locally when npm is

4
changelog.d/238.md Normal file
View file

@ -0,0 +1,4 @@
### Added
- Guard documentation availability markers against missing issue citations
and release candidates that already ship the cited work (#238).

View file

@ -113,12 +113,19 @@ the machinery at all:
self-hosted runner still wants it: the guard's value is the day self-hosted runner still wants it: the guard's value is the day
somebody adds one. somebody adds one.
This guide documents `main`. New machinery is marked **unreleased** This guide documents `main`. A marker is the literal token
here until a release tag ships it — and the release PR that ships the `**unreleased**` immediately followed by its issue citation (for example,
machinery clears, in that same PR, every marker its own assembled `(#238)`); whitespace between them may include a line break. A citation is
section makes false: the section cites its issues, each marker cites mandatory, because a marker the guard cannot trace is a marker it cannot
the same issue, and the release PR's diff is the one place both prove false. A token inside an inline-code span is a mention, not a marker;
halves are visible at once (#221). If an action does not exist at the 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
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 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 carries it; never mix a moving or newer ref into an otherwise exact-pin
consumer. In particular, `0.1.0` carries `changelog-armed`, consumer. In particular, `0.1.0` carries `changelog-armed`,
@ -148,7 +155,7 @@ the machinery at all:
- uses: heavy-duty/ceremony/actions/refs-not-closing@<pinned-tag> - uses: heavy-duty/ceremony/actions/refs-not-closing@<pinned-tag>
``` ```
`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 Adopt this caller with that ordinary pin bump; never point only this file
at a moving or newer ref. at a moving or newer ref.
7. **Labels automation** (optional but recommended): the two callers from 7. **Labels automation** (optional but recommended): the two callers from
@ -587,7 +594,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. pin goes red instead of quietly governing.
`RELEASES.md` joins that mirror with the first tag carrying ceremony#248. `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 `.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by
copying it ahead of their pinned doctrine set. copying it ahead of their pinned doctrine set.

142
test/marker-check.test.sh Normal file
View file

@ -0,0 +1,142 @@
#!/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 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
# 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"
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 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
# 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
## Unreleased
The marker token is `**unreleased**`.
EOF
printf -- '- A fragment may say **unreleased** without being documentation.\n' \
>"$TMP/exclusions/changelog.d/999.md"
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