forked from heavy-duty/ceremony
Merge pull request #273 from andriujoseba/build/238-marker-check
feat: guard unreleased documentation markers
This commit is contained in:
commit
05738ff0dd
5 changed files with 293 additions and 8 deletions
128
.github/scripts/marker-check.sh
vendored
Executable file
128
.github/scripts/marker-check.sh
vendored
Executable 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."
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
4
changelog.d/238.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Guard documentation availability markers against missing issue citations
|
||||||
|
and release candidates that already ship the cited work (#238).
|
||||||
|
|
@ -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
142
test/marker-check.test.sh
Normal 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
|
||||||
Loading…
Reference in a new issue