Compare commits
No commits in common. "main" and "0.3.0" have entirely different histories.
97 changed files with 1349 additions and 17458 deletions
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
|
||||||
|
# that demands rigor at the door defeats the room's purpose (issue #24,
|
||||||
|
# decision 4). If these prompts fight the flow in practice, delete them
|
||||||
|
# before adding fields to them.
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: What's the itch?
|
||||||
|
description: >-
|
||||||
|
Vague is fine — a bug, an idea, a "we should…". Triage turns this
|
||||||
|
into work (or an answer); you don't have to.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: What would "done" feel like?
|
||||||
|
description: Optional — a sketch of the outcome, if you can already see it.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
15
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
15
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Light on purpose — same reasoning as ideas.yml (issue #24, decision 4):
|
||||||
|
# optional prompts only, delete before hardening.
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: What's the question?
|
||||||
|
description: Ask it plainly — context and links help, none are required.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: What have you tried or read already?
|
||||||
|
description: Optional — saves the answerer a lap.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
15
.github/ISSUE_TEMPLATE/config.yml
vendored
15
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,12 +1,13 @@
|
||||||
# The new-issue interception (issue #24, decision 1): interception over
|
# The new-issue interception (issue #24, decision 1): interception over
|
||||||
# instruction — prose alone has already proven insufficient everywhere else
|
# instruction — prose alone has already proven insufficient everywhere else
|
||||||
# in this org. Blank issues stay disabled; the proposal contact link gives
|
# in this org. With blank issues disabled and exactly one form, a "New
|
||||||
# non-triage filers a reachable route beside triage's work-order form. That
|
# issue" click shows the discussion link first; that auto-suggest is native
|
||||||
# chooser is native forge behavior, no automation needed.
|
# GitHub behavior, no automation needed.
|
||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ideas, bugs, questions — file a Proposal
|
- name: Ideas, bugs, questions — start a Discussion
|
||||||
url: https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/new?template=proposal.yml
|
url: https://github.com/heavy-duty/ceremony/discussions
|
||||||
about: >-
|
about: >-
|
||||||
Anyone may file a proposal. Triage converts it into a work issue or
|
Humans (and agents) never file issues here — discussions are where
|
||||||
refuses it with reasons; only triage mints work issues (TRIAGE.md).
|
intent lives. Triage converges every discussion to an outcome, and
|
||||||
|
only triage mints issues (TRIAGE.md).
|
||||||
|
|
|
||||||
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
|
|
@ -1,34 +0,0 @@
|
||||||
# This intake form applies no labels: queue labels are triage's explicit act
|
|
||||||
# (LABELS.md), and the issue-flow sweep catches non-triage authors, so the form
|
|
||||||
# must not pre-judge the proposal's queue state (#24 D2).
|
|
||||||
name: Proposal (anyone)
|
|
||||||
description: >-
|
|
||||||
Share an idea, bug, question, or rough "we should…" for triage to convert
|
|
||||||
into work or refuse with reasons.
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: >-
|
|
||||||
Proposals are the low-bar intake door. Say what you noticed and why it
|
|
||||||
might matter; triage will decide whether it becomes a work issue.
|
|
||||||
- type: textarea
|
|
||||||
id: noticed
|
|
||||||
attributes:
|
|
||||||
label: What did you notice?
|
|
||||||
description: A rough idea, bug, question, or "we should…" is enough.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: why-it-matters
|
|
||||||
attributes:
|
|
||||||
label: Why might it matter?
|
|
||||||
description: Optional — describe the impact or opportunity if you can.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: known-context
|
|
||||||
attributes:
|
|
||||||
label: What do you already know?
|
|
||||||
description: Optional — add links, examples, constraints, or prior art.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
6
.github/ISSUE_TEMPLATE/work-order.yml
vendored
6
.github/ISSUE_TEMPLATE/work-order.yml
vendored
|
|
@ -6,8 +6,8 @@
|
||||||
# that (issue #24, decision 2).
|
# that (issue #24, decision 2).
|
||||||
name: Work order (triage only)
|
name: Work order (triage only)
|
||||||
description: >-
|
description: >-
|
||||||
The issue contract (TRIAGE.md) as a form. Only triage mints work issues —
|
The issue contract (TRIAGE.md) as a form. Only triage mints issues —
|
||||||
everyone else files a proposal. Triage may still compose by hand when
|
everyone else starts a Discussion. Triage may still compose by hand when
|
||||||
the form fights it: `gh issue create --body-file` bypasses forms and stays
|
the form fights it: `gh issue create --body-file` bypasses forms and stays
|
||||||
legitimate for the triage identity.
|
legitimate for the triage identity.
|
||||||
body:
|
body:
|
||||||
|
|
@ -25,7 +25,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: Context
|
label: Context
|
||||||
description: >-
|
description: >-
|
||||||
Why this exists, with links — the proposal it came from, the code
|
Why this exists, with links — the discussion it came from, the code
|
||||||
it touches (permalinks at a pinned SHA, so line references cannot
|
it touches (permalinks at a pinned SHA, so line references cannot
|
||||||
rot), prior art in sibling repos.
|
rot), prior art in sibling repos.
|
||||||
validations:
|
validations:
|
||||||
|
|
|
||||||
62
.github/labeler.yml
vendored
62
.github/labeler.yml
vendored
|
|
@ -7,32 +7,6 @@
|
||||||
# these globs implement. Scopes locate, they do not alert — a path that maps
|
# these globs implement. Scopes locate, they do not alert — a path that maps
|
||||||
# to nothing is fine (the mapping is advisory), so these rows chase the big
|
# to nothing is fine (the mapping is advisory), so these rows chase the big
|
||||||
# surfaces, not every file.
|
# surfaces, not every file.
|
||||||
#
|
|
||||||
# A row that matches everything is worse than a missing one: it costs the
|
|
||||||
# same silence and adds a wrong answer. `changelog.d/**` sat under
|
|
||||||
# scope:release-flow until #267 measured it — the last 20 PRs (#197–#263)
|
|
||||||
# all carried scope:release-flow and 3 of them touched a release surface,
|
|
||||||
# because BUILDER.md makes every behavior change write a fragment, so the
|
|
||||||
# glob was "any PR that changes behavior" by doctrine. CHANGELOG.md stays:
|
|
||||||
# the same doctrine forbids editing it for an entry, so only the release PR
|
|
||||||
# does. The other rows #267 added — the issueflow reconciler, the three
|
|
||||||
# unmapped guard actions, RELEASES.md, README (which this tree spells
|
|
||||||
# README.md, so the old glob could match nothing) — are the same read of the
|
|
||||||
# same file, gaps rather than wrong answers.
|
|
||||||
#
|
|
||||||
# #302 is the same read once more, from #300's review: lib/attention.sh had
|
|
||||||
# #267 D4's premise exactly (both reconcilers source it, nothing release-side
|
|
||||||
# does) and was not in the rows — a wrong answer, not a gap. The sweep
|
|
||||||
# workflow pair joins beside its trigger pair: the sweeps detached in #209
|
|
||||||
# and took the reconcile jobs and the cron with them. Two asymmetries are
|
|
||||||
# deliberate, not drift: the TESTS of the shared lib/ files take
|
|
||||||
# scope:labels alone, because lib/ruling.sh and lib/read.sh wear
|
|
||||||
# scope:release-flow only through the lib/** glob being kept whole and a
|
|
||||||
# test file inherits no such glob; and there is still no test/** or
|
|
||||||
# .github/scripts/** catch-all, because both directories span all four
|
|
||||||
# scopes — a catch-all is the changelog.d/** defect again, 100% recall and
|
|
||||||
# no locating power. The enumeration is the price of a test locating its
|
|
||||||
# subject.
|
|
||||||
scope:release-flow:
|
scope:release-flow:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
|
|
@ -44,75 +18,43 @@ scope:release-flow:
|
||||||
- bin/**
|
- bin/**
|
||||||
- VERSION
|
- VERSION
|
||||||
- CHANGELOG.md
|
- CHANGELOG.md
|
||||||
|
- changelog.d/**
|
||||||
- drills/**
|
- drills/**
|
||||||
- test/decide.test.sh
|
- test/decide.test.sh
|
||||||
- test/preflight.test.sh
|
|
||||||
- test/facts.test.sh
|
- test/facts.test.sh
|
||||||
- test/release-chain.test.sh
|
- test/release-chain.test.sh
|
||||||
- test/version.test.sh
|
- test/version.test.sh
|
||||||
- test/changelog.test.sh
|
- test/changelog.test.sh
|
||||||
- test/self-ref.test.sh
|
- test/self-ref.test.sh
|
||||||
- test/changelog-assemble.test.sh
|
|
||||||
- .github/scripts/release-path.sh
|
|
||||||
- test/release-path.test.sh
|
|
||||||
scope:guards:
|
scope:guards:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- actions/changelog-armed/**
|
- actions/changelog-armed/**
|
||||||
- actions/changelog-assembled/**
|
|
||||||
- actions/changelog-monotonic/**
|
- actions/changelog-monotonic/**
|
||||||
- actions/docs-sync/**
|
|
||||||
- actions/drill-recorded/**
|
- actions/drill-recorded/**
|
||||||
- actions/refs-not-closing/**
|
|
||||||
- actions/runner-isolated/**
|
|
||||||
- .github/workflows/refs-guard.yml
|
|
||||||
- test/changelog-armed.test.sh
|
- test/changelog-armed.test.sh
|
||||||
- test/changelog-assembled.test.sh
|
|
||||||
- test/changelog-monotonic.test.sh
|
- test/changelog-monotonic.test.sh
|
||||||
- test/docs-sync.test.sh
|
|
||||||
- test/drill-recorded.test.sh
|
- test/drill-recorded.test.sh
|
||||||
- test/refs-not-closing.test.sh
|
|
||||||
- test/runner-isolated.test.sh
|
|
||||||
- .github/scripts/marker-check.sh
|
|
||||||
- test/marker-check.test.sh
|
|
||||||
- .github/scripts/vendored-check.sh
|
|
||||||
- test/vendored.test.sh
|
|
||||||
scope:labels:
|
scope:labels:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- .github/workflows/labels.yml
|
- .github/workflows/labels.yml
|
||||||
- .github/workflows/self-labels.yml
|
- .github/workflows/self-labels.yml
|
||||||
- .github/workflows/labels-sweep.yml
|
|
||||||
- .github/workflows/self-labels-sweep.yml
|
|
||||||
- .github/labeler.yml
|
- .github/labeler.yml
|
||||||
- .github/labels.conf
|
- .github/labels.conf
|
||||||
- actions/issueflow-reconcile/**
|
|
||||||
- actions/labels-reconcile/**
|
- actions/labels-reconcile/**
|
||||||
- actions/labels-scope/**
|
- actions/labels-scope/**
|
||||||
# shared by both reconcilers; lib/** keeps scope:release-flow too,
|
|
||||||
# and a mixed file honestly wears both labels (#267 D4, #302 D1)
|
|
||||||
- lib/read.sh
|
|
||||||
- lib/ruling.sh
|
|
||||||
- lib/attention.sh
|
|
||||||
- LABELS.md
|
- LABELS.md
|
||||||
- test/issueflow-reconcile.test.sh
|
|
||||||
- test/labels.test.sh
|
- test/labels.test.sh
|
||||||
- test/labels-reconcile.test.sh
|
- test/labels-reconcile.test.sh
|
||||||
- test/labels-scope.test.sh
|
- test/labels-scope.test.sh
|
||||||
# tests of the shared lib/ files: scope:labels ALONE — a test
|
|
||||||
# inherits no lib/** glob, so its row is the one scope its subject
|
|
||||||
# actually locates (#302 D3)
|
|
||||||
- test/attention.test.sh
|
|
||||||
- test/ruling.test.sh
|
|
||||||
- test/labels-triggers.test.sh
|
|
||||||
scope:docs:
|
scope:docs:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- README.md
|
- README
|
||||||
- docs/**
|
- docs/**
|
||||||
- AGENTS.md
|
- AGENTS.md
|
||||||
- BUILDER.md
|
- BUILDER.md
|
||||||
- RELEASES.md
|
|
||||||
- REVIEWER.md
|
- REVIEWER.md
|
||||||
- TRIAGE.md
|
- TRIAGE.md
|
||||||
- CONTRIBUTING.md
|
- CONTRIBUTING.md
|
||||||
|
|
|
||||||
4
.github/labels.conf
vendored
4
.github/labels.conf
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
triage-actors=claude-bot-andresmgsl
|
triage-actors=dan-claude-bot
|
||||||
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
|
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
|
||||||
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
|
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
|
||||||
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy
|
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy
|
||||||
|
|
|
||||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
|
|
@ -16,5 +16,5 @@ to the issue for triage to amend, not silently unshipped. -->
|
||||||
|
|
||||||
## Round log
|
## Round log
|
||||||
|
|
||||||
<!-- The engine appends each whole-round reply here, newest last: what
|
<!-- Append each round's summary here: what changed, what was verified.
|
||||||
changed and what was verified. Builders write the reply, not this section. -->
|
Rounds are answered whole — one reply covering every point. -->
|
||||||
|
|
|
||||||
128
.github/scripts/marker-check.sh
vendored
128
.github/scripts/marker-check.sh
vendored
|
|
@ -1,128 +0,0 @@
|
||||||
#!/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."
|
|
||||||
25
.github/scripts/release-path.sh
vendored
25
.github/scripts/release-path.sh
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# The release doors' executable path (discussion #217; issue #237). The
|
|
||||||
# 0.5.0 record had to explain why lib/ruling.sh changed without changing a
|
|
||||||
# door. Keep this list executable so a doors-unchanged record measures the
|
|
||||||
# workflow and only the scripts it actually runs, while the test catches a
|
|
||||||
# new or removed dependency before a record can silently omit it.
|
|
||||||
#
|
|
||||||
# On this tree the doors also speak the forge shim: #191 ported lib/facts.sh
|
|
||||||
# and release.yml off `gh` so a Forgejo consumer can publish at all, which
|
|
||||||
# makes lib/forge.sh part of the doors' executable path here. The backends it
|
|
||||||
# loads (lib/forge-github.sh, lib/forge-forgejo.sh) are sourced through
|
|
||||||
# $FORGE_LIB_DIR at run time rather than by a literal `.` line, so the
|
|
||||||
# transitive scan cannot see them and listing them would read as a stale
|
|
||||||
# path; lib/forge.sh standing for the trio is the honest entry (#198).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
printf '%s\n' \
|
|
||||||
.github/workflows/release.yml \
|
|
||||||
bin/ \
|
|
||||||
lib/version.sh \
|
|
||||||
lib/decide.sh \
|
|
||||||
lib/preflight.sh \
|
|
||||||
lib/facts.sh \
|
|
||||||
lib/changelog.sh \
|
|
||||||
lib/forge.sh
|
|
||||||
234
.github/scripts/vendored-check.sh
vendored
234
.github/scripts/vendored-check.sh
vendored
|
|
@ -1,234 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# The vendored-manifest self-guard (issue #251; #248's near-miss). Consumers
|
|
||||||
# mirror the agent-facing doc set declared by docs/VENDORED.txt, and
|
|
||||||
# actions/docs-sync already enforces "manifest ∪ .ceremony/, nothing else"
|
|
||||||
# on the CONSUMER side. Nothing enforced the other end: a new doctrine file
|
|
||||||
# could land at ceremony's root and nobody add it to the manifest, and the
|
|
||||||
# miss is SILENT — docs-sync asserts byte-identity for the files the
|
|
||||||
# manifest names, so a doc it omits is never checked and every consumer
|
|
||||||
# drifts doctrine-blind with green guards. #248 (RELEASES.md) nearly shipped
|
|
||||||
# that way, caught only by a hand-written `grep -Fx RELEASES.md` row in
|
|
||||||
# test/docs-sync.test.sh — the hardcoded list this guard abolishes, one
|
|
||||||
# layer down. That row is deleted; this script carries its intent.
|
|
||||||
#
|
|
||||||
# Two directions, two mechanisms, because only one of them can be a scan
|
|
||||||
# (#251 D2):
|
|
||||||
#
|
|
||||||
# * MANIFEST → TREE is a scan: every entry resolves to a regular,
|
|
||||||
# non-empty, tracked file at the declared path — no symlink (PR #43:
|
|
||||||
# a symlink read as doctrine while staying invisible), no directory,
|
|
||||||
# no `../` escape.
|
|
||||||
# * TREE → MANIFEST cannot scan, because nothing in the tree answers
|
|
||||||
# "which files are vendorable" — the manifest is the only
|
|
||||||
# machine-readable notion of it. So it gets a CLOSED-WORLD RULE
|
|
||||||
# instead: every `*.md` at the repository ROOT is either in the
|
|
||||||
# manifest or in the exemption list below. Adding a root doc then
|
|
||||||
# forces a one-line decision — vendor it or exempt it — and the
|
|
||||||
# refusal names the file and both fixes.
|
|
||||||
#
|
|
||||||
# The rule is ROOT-LEVEL `*.md` ONLY. It does not walk docs/, actions/ or
|
|
||||||
# drills/: those hold no agent-facing doctrine, and a recursive version
|
|
||||||
# would grow the exemption list past the length at which a reviewer still
|
|
||||||
# reads it — which is the failure this rule is shaped against.
|
|
||||||
#
|
|
||||||
# The exemption list lives HERE, in the script. CONTRIBUTING.md's
|
|
||||||
# vendored-set sentence is documentation, never an input: two declarations
|
|
||||||
# of the same set is the drift the manifest exists to prevent.
|
|
||||||
#
|
|
||||||
# Usage: vendored-check.sh [tree-dir] (default: the repo root — the CI
|
|
||||||
# step; tests point it at fixture trees)
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MANIFEST="docs/VENDORED.txt"
|
|
||||||
|
|
||||||
tree="${1:-.}"
|
|
||||||
|
|
||||||
# The root docs that are deliberately ceremony-only. Each carries the reason
|
|
||||||
# it is not vendored, because the reason is what lets the next reviewer
|
|
||||||
# judge the next addition. Prints the reason and returns 0 when exempt.
|
|
||||||
exempt_reason() {
|
|
||||||
case "$1" in
|
|
||||||
README.md)
|
|
||||||
echo "ceremony's own front page — a consumer's router is AGENTS.md, not this repo's README"
|
|
||||||
;;
|
|
||||||
CONTRIBUTING.md)
|
|
||||||
echo "repo-specific facts (this repo's roster, scopes and conventions); every governed repo writes its own"
|
|
||||||
;;
|
|
||||||
CHANGELOG.md)
|
|
||||||
echo "ceremony's own release history; a consumer keeps its own"
|
|
||||||
;;
|
|
||||||
FLEET.md)
|
|
||||||
echo "the operator's fleet map — about running the fleet, not about how a governed repo works"
|
|
||||||
;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
die() {
|
|
||||||
printf 'vendored-check: %s\n' "$@" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_file="$tree/$MANIFEST"
|
|
||||||
[ -f "$manifest_file" ] || die \
|
|
||||||
"no $MANIFEST under $tree — the manifest is the sole declaration of the" \
|
|
||||||
" vendored doc set, and this guard has nothing to guard without it."
|
|
||||||
|
|
||||||
# Blank lines are skipped, exactly as actions/docs-sync reads it: the guard
|
|
||||||
# and the tool must accept the same file, or one of them is the bug.
|
|
||||||
mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true)
|
|
||||||
[ "${#manifest[@]}" -gt 0 ] || die \
|
|
||||||
"$MANIFEST is empty — an empty doctrine set is a ceremony bug, not a repo" \
|
|
||||||
" with no rules."
|
|
||||||
|
|
||||||
# Whether the tracked-file assertion can bind: only when the tree IS a git
|
|
||||||
# work tree root. Fixture trees are plain directories, and asserting
|
|
||||||
# tracked-ness against an enclosing repository would be asserting about the
|
|
||||||
# wrong tree.
|
|
||||||
#
|
|
||||||
# When it cannot bind, SAY SO. This guard's whole argument is that a silent
|
|
||||||
# miss is worse than a loud one, and a guard that quietly stops asserting one
|
|
||||||
# of its four properties is exactly that shape — so the skip is announced on
|
|
||||||
# every run, green or red, rather than inferred from the absence of a
|
|
||||||
# refusal (#251 round 1).
|
|
||||||
tracked_check=no
|
|
||||||
tracked_note="tracked-ness NOT asserted: $tree is not a git work tree root, so
|
|
||||||
'is this file in the tag's tree' cannot be answered about THIS tree. The
|
|
||||||
other three manifest assertions (regular file, non-empty, no
|
|
||||||
symlink/dir/escape) still bind."
|
|
||||||
if command -v git >/dev/null 2>&1; then
|
|
||||||
toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)"
|
|
||||||
if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then
|
|
||||||
tracked_check=yes
|
|
||||||
tracked_note=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Every refusal is collected and reported together, one multi-line string
|
|
||||||
# per offending file: a guard that stops at the first problem makes a
|
|
||||||
# builder pay one CI round per file.
|
|
||||||
problems=()
|
|
||||||
|
|
||||||
# --- manifest → tree ---------------------------------------------------------
|
|
||||||
|
|
||||||
for entry in "${manifest[@]}"; do
|
|
||||||
case "$entry" in
|
|
||||||
/* | *..*)
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"$MANIFEST names '$entry' — an absolute path or a '..' escape. The" \
|
|
||||||
" mirror writes only inside a consumer's .ceremony/, so a path that" \
|
|
||||||
" leaves it is never vendorable." \
|
|
||||||
" Fix: name the path relative to the repository root, with no '..'."
|
|
||||||
)")
|
|
||||||
continue
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
path="$tree/$entry"
|
|
||||||
|
|
||||||
# -L before -f: `[ -f ]` follows the link, so a symlink to a real file
|
|
||||||
# would otherwise pass as a regular one.
|
|
||||||
if [ -L "$path" ]; then
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"$MANIFEST names '$entry', which is a SYMLINK. A symlink vendors as" \
|
|
||||||
" doctrine while its content lives somewhere the mirror never checks" \
|
|
||||||
" (PR #43's round: it read as doctrine and stayed invisible)." \
|
|
||||||
" Fix: make '$entry' a regular file, or drop the entry from $MANIFEST."
|
|
||||||
)")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [ -d "$path" ]; then
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"$MANIFEST names '$entry', which is a DIRECTORY. The manifest declares" \
|
|
||||||
" files, one per line — a directory entry vendors nothing." \
|
|
||||||
" Fix: name each file under '$entry' on its own line, or drop the entry."
|
|
||||||
)")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [ ! -f "$path" ]; then
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"$MANIFEST names '$entry' but the tree has no such file. Every consumer" \
|
|
||||||
" mirroring this ref would fail on it." \
|
|
||||||
" Fix: add '$entry' to the tree, or remove it from $MANIFEST."
|
|
||||||
)")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [ ! -s "$path" ]; then
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"$MANIFEST names '$entry', which is EMPTY. An empty file vendors as" \
|
|
||||||
" doctrine that says nothing, and is read as doctrine anyway." \
|
|
||||||
" Fix: write '$entry', or remove it from $MANIFEST."
|
|
||||||
)")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [ "$tracked_check" = yes ] &&
|
|
||||||
! git -C "$tree" ls-files --error-unmatch -- "$entry" >/dev/null 2>&1; then
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"$MANIFEST names '$entry', which is not TRACKED. A file absent from the" \
|
|
||||||
" tag's tree cannot be fetched by a consumer syncing at that tag," \
|
|
||||||
" however present it is on this machine." \
|
|
||||||
" Fix: git add '$entry', or remove it from $MANIFEST."
|
|
||||||
)")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- tree → manifest: the closed world over root `*.md` ----------------------
|
|
||||||
|
|
||||||
in_manifest() {
|
|
||||||
local p
|
|
||||||
for p in "${manifest[@]}"; do
|
|
||||||
[ "$p" = "$1" ] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
shopt -s nullglob
|
|
||||||
exempted=()
|
|
||||||
vendored=()
|
|
||||||
for path in "$tree"/*.md; do
|
|
||||||
doc="${path##*/}"
|
|
||||||
if in_manifest "$doc"; then
|
|
||||||
vendored+=("$doc")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if reason="$(exempt_reason "$doc")"; then
|
|
||||||
exempted+=("$doc — $reason")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
problems+=("$(
|
|
||||||
printf '%s\n' \
|
|
||||||
"'$doc' is a root doc in NEITHER list. Every root *.md is either vendored" \
|
|
||||||
" doctrine — mirrored into every governed repo at .ceremony/ — or" \
|
|
||||||
" deliberately ceremony-only, and nothing in the tree says which, so the" \
|
|
||||||
" decision has to be written down. Fix, one of:" \
|
|
||||||
" * add '$doc' to $MANIFEST, if it is agent-facing doctrine that every" \
|
|
||||||
" governed repo must carry;" \
|
|
||||||
" * add '$doc' to the exemption list in" \
|
|
||||||
" .github/scripts/vendored-check.sh, with the reason it stays" \
|
|
||||||
" ceremony-only."
|
|
||||||
)")
|
|
||||||
done
|
|
||||||
shopt -u nullglob
|
|
||||||
|
|
||||||
if [ "${#problems[@]}" -gt 0 ]; then
|
|
||||||
{
|
|
||||||
printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \
|
|
||||||
"${#problems[@]}"
|
|
||||||
printf '%s\n\n' "${problems[@]}"
|
|
||||||
[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note"
|
|
||||||
} >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \
|
|
||||||
"${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}"
|
|
||||||
[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note"
|
|
||||||
[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}"
|
|
||||||
[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}"
|
|
||||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -15,18 +15,6 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
|
|
||||||
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
|
|
||||||
# actionlint already self-installs below — the same for shellcheck, so a
|
|
||||||
# green head is reachable once a ceremony runner is online (#188).
|
|
||||||
- name: Install shellcheck
|
|
||||||
env:
|
|
||||||
SHELLCHECK_VERSION: 0.10.0
|
|
||||||
run: |
|
|
||||||
curl -fsSLo shellcheck.tar.xz \
|
|
||||||
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
|
|
||||||
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
|
|
||||||
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
|
|
||||||
- name: Shellcheck
|
- name: Shellcheck
|
||||||
run: bash .github/scripts/shellcheck-all.sh
|
run: bash .github/scripts/shellcheck-all.sh
|
||||||
- name: Install actionlint
|
- name: Install actionlint
|
||||||
|
|
@ -43,28 +31,6 @@ 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: Vendored manifest
|
|
||||||
# The manifest rules (issue #251; #248's near-miss): a doctrine file
|
|
||||||
# at the root that nobody added to docs/VENDORED.txt is invisible to
|
|
||||||
# every consumer's docs-sync, so it fails CI here instead.
|
|
||||||
run: bash .github/scripts/vendored-check.sh
|
|
||||||
- name: Fetch the recorded upstream commit
|
|
||||||
# test/upstream-delta.test.sh REFUSES when the recorded object is
|
|
||||||
# absent rather than calling it unverifiable (#200). "Runs offline"
|
|
||||||
# means the test reads local evidence — it does not mean CI may omit
|
|
||||||
# the evidence and pass. This step supplies it; the test never reaches
|
|
||||||
# the network itself.
|
|
||||||
run: |
|
|
||||||
ref="$(grep -vE '^[[:space:]]*(#|$)' .upstream-ref | head -n1)"
|
|
||||||
git fetch --no-tags --depth=1 \
|
|
||||||
https://github.com/heavy-duty/ceremony.git "$ref" || {
|
|
||||||
echo "::error::could not fetch the recorded upstream commit $ref" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
- 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
|
||||||
|
|
|
||||||
145
.github/workflows/labels-sweep.yml
vendored
145
.github/workflows/labels-sweep.yml
vendored
|
|
@ -1,145 +0,0 @@
|
||||||
name: labels-sweep
|
|
||||||
# Reusable sweep half of the labels automation — the reconcile + issueflow
|
|
||||||
# jobs that rode labels.yml until #209. Triggers and permissions live in the
|
|
||||||
# caller; docs/CONSUMERS.md carries the complete caller stub
|
|
||||||
# (workflow_dispatch plus the hourly cron, which relocated here with the
|
|
||||||
# sweep). Issue events and same-repository PR events still yield a sweep within
|
|
||||||
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
|
|
||||||
# events. Fork-headed PR events carry a read-only token on this Forgejo, so
|
|
||||||
# state, blocker, and handoff reconciliation waits for the caller's scheduled
|
|
||||||
# cadence; the sweep does not apply path-derived scope labels (#241).
|
|
||||||
#
|
|
||||||
# Detached on purpose (#209): every sweep covers every open PR and all
|
|
||||||
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
|
||||||
# one-running-plus-one-pending queue records every extra run as CANCELLED.
|
|
||||||
# That displacement is semantically lossless — the surviving sweep does the
|
|
||||||
# displaced run's work — but while the sweep rode pull_request_target runs
|
|
||||||
# the ❌ landed on that PR's checks and read as red CI, with no manual
|
|
||||||
# escape hatch: GitHub refuses to rerun a queue-displaced run (crew#250).
|
|
||||||
# And displacement is the steady state of a working fleet, not a spike —
|
|
||||||
# one panel request emits one review_requested event per reviewer, so
|
|
||||||
# every review round over-fills the one-running-plus-one-pending queue.
|
|
||||||
# Here a displaced run attaches to no PR: the cancellations live on the
|
|
||||||
# Actions tab only.
|
|
||||||
#
|
|
||||||
# Bootstrap semantics: a manual dispatch of the caller bootstraps the
|
|
||||||
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what
|
|
||||||
# dispatching the labels caller did before the split. The trigger job's
|
|
||||||
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty
|
|
||||||
# for every issue and same-repository PR wake, the same reason cron runs never
|
|
||||||
# bootstrapped.
|
|
||||||
#
|
|
||||||
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
|
||||||
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's
|
|
||||||
# workflow_dispatch is one of the two documented exemptions; this workflow
|
|
||||||
# dispatches nothing). Agent writes use a PAT and therefore do trigger —
|
|
||||||
# exactly the asymmetry wanted.
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
bootstrap:
|
|
||||||
description: >-
|
|
||||||
Bootstrap the label taxonomy before sweeping. The CALLER passes
|
|
||||||
this through from its own workflow_dispatch input. The measured
|
|
||||||
invariant (ceremony#215): the value must be DECLARED here and
|
|
||||||
EXPLICITLY passed — on this instance the called workflow did not
|
|
||||||
see the caller's event inputs as an implicit substitute (runs
|
|
||||||
459/523 bootstrapped on a bootstrap=no dispatch) while the
|
|
||||||
top-level caller received the value in both contexts (probe runs
|
|
||||||
6/7). Absent means "no": an event- or cron-woken sweep must never
|
|
||||||
re-upsert ~20 labels.
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: "no"
|
|
||||||
pr_workflow_name:
|
|
||||||
description: >-
|
|
||||||
The `name:` of the consumer's PR-facing labels caller, exported
|
|
||||||
to the reconcile step as SELF_WORKFLOW so the sweep can leave
|
|
||||||
the label machinery's own check entries (scope, trigger) out of
|
|
||||||
its CI verdict: a red trigger means "fix the caller", which no
|
|
||||||
PR edit can do, so it must never count toward blocker:ci-red.
|
|
||||||
Read by the #208 reconciler; harmless to earlier ones.
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: labels
|
|
||||||
|
|
||||||
env:
|
|
||||||
# A called workflow arrives without its repository. Keep this literal pin
|
|
||||||
# aligned with the ceremony release consumed by callers (issue #9 D3).
|
|
||||||
CEREMONY_SELF_REF: "0.6.3"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
reconcile:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
|
||||||
# dispatched runs must serialize or two sweeps race the same PR's labels
|
|
||||||
# and both pass the request-the-human-once guard.
|
|
||||||
concurrency:
|
|
||||||
group: labels-reconcile
|
|
||||||
cancel-in-progress: false
|
|
||||||
steps:
|
|
||||||
# No PR code is ever checked out or executed: the sweep checks out
|
|
||||||
# the consumer's default branch and the pinned ceremony
|
|
||||||
# implementation only. Keep it that way.
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: ${{ github.repository }}
|
|
||||||
ref: ${{ github.event.repository.default_branch }}
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# The self-consumption bypass — release.yml's twin, and load-bearing
|
|
||||||
# for the same reason (#11): ceremony's own labels bootstrap must
|
|
||||||
# run BEFORE any release tag exists for this checkout to fetch — the
|
|
||||||
# release label the merge door reads is created by that dispatch, so
|
|
||||||
# without the bypass the first release deadlocks on its own pin. The
|
|
||||||
# base-branch checkout above already IS ceremony on the dogfood
|
|
||||||
# path.
|
|
||||||
if: github.repository != 'heavy-duty/ceremony'
|
|
||||||
with:
|
|
||||||
repository: heavy-duty/ceremony
|
|
||||||
ref: ${{ env.CEREMONY_SELF_REF }}
|
|
||||||
path: .ceremony-src
|
|
||||||
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
|
|
||||||
# a literal — the same fork release.yml's CEREMONY_DIR env line
|
|
||||||
# papers over for `run:` steps, which composite `uses:` has no
|
|
||||||
# equivalent of.
|
|
||||||
#
|
|
||||||
# bootstrap: read from the DECLARED workflow_call input and nothing
|
|
||||||
# else. The old gate read `github.event.inputs.bootstrap` from inside
|
|
||||||
# this called workflow, and on this instance that context arrived
|
|
||||||
# empty (runs 459/523: every dispatch-woken sweep bootstrapped on a
|
|
||||||
# bootstrap=no body) while the top-level caller received the value in
|
|
||||||
# both contexts (probe runs 6/7) — ceremony#215. The reliable channel
|
|
||||||
# is declare-and-pass, so that is the only one used. The caller passes
|
|
||||||
# the value through `with.bootstrap`; anything not exactly yes|no is
|
|
||||||
# fed through for labels-reconcile's own validation to judge, so a
|
|
||||||
# typo refuses loudly instead of silently bootstrapping.
|
|
||||||
- name: reconcile state + stale
|
|
||||||
if: github.repository != 'heavy-duty/ceremony'
|
|
||||||
uses: ./.ceremony-src/actions/labels-reconcile
|
|
||||||
with:
|
|
||||||
bootstrap: ${{ inputs.bootstrap }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
SELF_WORKFLOW: ${{ inputs.pr_workflow_name }}
|
|
||||||
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
|
|
||||||
if: github.repository == 'heavy-duty/ceremony'
|
|
||||||
uses: ./actions/labels-reconcile
|
|
||||||
with:
|
|
||||||
bootstrap: ${{ inputs.bootstrap }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
SELF_WORKFLOW: ${{ inputs.pr_workflow_name }}
|
|
||||||
- name: reconcile issue flow
|
|
||||||
if: github.repository != 'heavy-duty/ceremony'
|
|
||||||
uses: ./.ceremony-src/actions/issueflow-reconcile
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
|
|
||||||
if: github.repository == 'heavy-duty/ceremony'
|
|
||||||
uses: ./actions/issueflow-reconcile
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
229
.github/workflows/labels.yml
vendored
229
.github/workflows/labels.yml
vendored
|
|
@ -2,73 +2,41 @@ name: labels
|
||||||
# Reusable half of the labels automation. Triggers and permissions live in
|
# Reusable half of the labels automation. Triggers and permissions live in
|
||||||
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
||||||
#
|
#
|
||||||
# The caller uses pull_request_target, not pull_request, so same-repository PRs
|
# The caller uses pull_request_target, not pull_request: every PR in this
|
||||||
# keep the base repository's write token without running PR code. On this
|
# family arrives from a fork, where pull_request runs with a READ-ONLY token
|
||||||
# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
|
# and cannot label anything. _target is safe in this workflow because no PR
|
||||||
# token. Those runs therefore attempt no writes. The scheduled sweep later
|
# code is ever checked out or executed — scope reads changed paths and the
|
||||||
# reconciles state, blockers, and handoff, but it does not apply path-derived
|
# path mapping via the API and checks out only the ceremony implementation,
|
||||||
# scope labels; consumers that require those labels on fork heads apply them
|
# and reconcile checks out the BASE branch only. Keep it that way.
|
||||||
# manually. The explicit fork_head job below records that disposition as a
|
|
||||||
# successful check. Both write paths execute only for same-repository heads.
|
|
||||||
# Scope reads changed paths and the path mapping through the API and checks out
|
|
||||||
# only the ceremony implementation. Keep it that way (#241).
|
|
||||||
#
|
#
|
||||||
# The reconcile sweep lived here until #209. Riding the PR-triggered run
|
# There is no pull_request_review_target, so a review landing cannot wake this
|
||||||
# meant every displacement in the sweep's shared concurrency queue recorded
|
# workflow directly — and the */15 cron is advisory: GitHub deprioritises
|
||||||
# a CANCELLED `reconcile` check on some PR — read as red CI by every human
|
# short intervals hard enough that a quiet repo goes hours between ticks. The
|
||||||
# and agent, though the surviving sweep does the displaced run's work. Two
|
# handoff wakes the sweep itself: the author sets state:needs-human, and the
|
||||||
# field facts made that untenable (crew#250): a displaced run cannot be
|
# caller's `labeled` event confirms or corrects that optimistic write within
|
||||||
# rerun — `gh run rerun`, `--failed`, and `--job` all refuse — so a victim
|
# seconds. The cron stays as the last resort for a forgotten handoff.
|
||||||
# PR has no manual escape hatch; and the displacing burst is deterministic,
|
|
||||||
# one `review_requested` event per panelist per request, so every review
|
|
||||||
# round displaces runs and the rate scales with panel size. The
|
|
||||||
# sweep now lives in labels-sweep.yml behind its own caller, and the
|
|
||||||
# trigger job below is its instant wake: it fires on every issue event and
|
|
||||||
# same-repository PR event this caller subscribes to, preserving that part of
|
|
||||||
# the surface that used to run reconcile directly. Same-repository wake latency
|
|
||||||
# (#137) remains seconds-scale, while a displaced sweep cancels on the Actions
|
|
||||||
# tab, attached to no PR. Fork-headed runs cannot dispatch with their read-only
|
|
||||||
# token, so state, blocker, and handoff reconciliation waits for the scheduled
|
|
||||||
# sweep; path-derived scope labels are not applied to fork heads. PR checks show
|
|
||||||
# scope + trigger for same-repository heads, or fork_head for fork heads.
|
|
||||||
#
|
#
|
||||||
# This cannot loop: the trigger's dispatch and the reconciler's label
|
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
||||||
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
|
||||||
# GITHUB_TOKEN-raised events — workflow_dispatch and repository_dispatch
|
# PAT and therefore do trigger — exactly the asymmetry wanted.
|
||||||
# are the two documented exemptions, which is exactly why the trigger can
|
|
||||||
# wake the sweep with no PAT anywhere in the path — and the sweep itself
|
|
||||||
# dispatches nothing. Agent writes use a PAT and therefore do trigger —
|
|
||||||
# exactly the asymmetry wanted.
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
|
||||||
sweep_workflow:
|
|
||||||
description: >-
|
|
||||||
Filename of the consumer's sweep caller — the workflow that
|
|
||||||
calls labels-sweep.yml (docs/CONSUMERS.md carries the stub).
|
|
||||||
The trigger job dispatches it by this name. Override it only
|
|
||||||
when the caller file is not named labels-sweep.yml (ceremony's
|
|
||||||
own dogfood names it self-labels-sweep.yml).
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: labels-sweep.yml
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# A called workflow arrives without its repository. Keep this literal pin
|
# A called workflow arrives without its repository. Keep this literal pin
|
||||||
# aligned with the ceremony release consumed by callers (issue #9 D3).
|
# aligned with the ceremony release consumed by callers (issue #9 D3).
|
||||||
CEREMONY_SELF_REF: "0.6.3"
|
CEREMONY_SELF_REF: "0.3.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
scope:
|
scope:
|
||||||
# Not on labeled/unlabeled: those events change no paths, so scope has
|
# Not on labeled/unlabeled: those events change no paths, so scope has
|
||||||
# nothing new to derive — and label churn is precisely what they are.
|
# nothing new to derive — and label churn is precisely what they are.
|
||||||
# review_requested/review_request_removed likewise change no paths — they
|
# review_requested/review_request_removed likewise change no paths — they
|
||||||
# exist to wake the sweep (#137) — and running labeler on them widens
|
# exist to wake reconcile (#137) — and running labeler on them widens
|
||||||
# exactly the window #130 documents, where a label written during a
|
# exactly the window #130 documents, where a label written during a
|
||||||
# scope run is clobbered.
|
# scope run is clobbered.
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'pull_request_target' &&
|
github.event_name == 'pull_request_target' &&
|
||||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
||||||
github.event.action != 'labeled' &&
|
github.event.action != 'labeled' &&
|
||||||
github.event.action != 'unlabeled' &&
|
github.event.action != 'unlabeled' &&
|
||||||
github.event.action != 'review_requested' &&
|
github.event.action != 'review_requested' &&
|
||||||
|
|
@ -114,110 +82,65 @@ jobs:
|
||||||
# the mapping it is judged by
|
# the mapping it is judged by
|
||||||
CONFIG_REF: ${{ github.sha }}
|
CONFIG_REF: ${{ github.sha }}
|
||||||
|
|
||||||
trigger:
|
reconcile:
|
||||||
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
|
|
||||||
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
|
|
||||||
# gives their pull_request_target run a read-only token (#241); fork_head
|
|
||||||
# records which reconciliation waits for the sweep and that path-derived
|
|
||||||
# scope labels are not applied there. Non-PR events include workflow_dispatch:
|
|
||||||
# excluding it would make a dispatched labels caller silently do nothing.
|
|
||||||
#
|
|
||||||
# LOUD on failure — never `|| true`: a red trigger is the
|
|
||||||
# misconfiguration alarm. A consumer that bumps the pin without adding
|
|
||||||
# the sweep caller (workflow-not-found), without its declared
|
|
||||||
# `bootstrap` input (unexpected input), or without `actions: write`
|
|
||||||
# on this caller (permission denied) fails HERE, visibly on the PR,
|
|
||||||
# instead of silently never sweeping again.
|
|
||||||
if: >-
|
|
||||||
github.event_name != 'pull_request_target' ||
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||||
|
# PR-event runs must serialize or two sweeps race the same PR's labels
|
||||||
|
# and both pass the request-the-human-once guard.
|
||||||
|
concurrency:
|
||||||
|
group: labels-reconcile
|
||||||
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
- name: dispatch the sweep
|
# pull_request_target is required for fork PR write permission. It is
|
||||||
|
# safe here because no PR code is ever checked out or executed:
|
||||||
|
# labels-scope reads the mapping and changed paths via the API, and
|
||||||
|
# reconcile checks out the BASE branch only. Keep it that way.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# The self-consumption bypass — release.yml's twin, and load-bearing
|
||||||
|
# for the same reason (#11): ceremony's own labels bootstrap must
|
||||||
|
# run BEFORE any release tag exists for this checkout to fetch — the
|
||||||
|
# release label the merge door reads is created by that dispatch, so
|
||||||
|
# without the bypass the first release deadlocks on its own pin. The
|
||||||
|
# base-branch checkout above already IS ceremony on the dogfood
|
||||||
|
# path.
|
||||||
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
|
with:
|
||||||
|
repository: heavy-duty/ceremony
|
||||||
|
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||||
|
path: .ceremony-src
|
||||||
|
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
|
||||||
|
# a literal — the same fork release.yml's CEREMONY_DIR env line
|
||||||
|
# papers over for `run:` steps, which composite `uses:` has no
|
||||||
|
# equivalent of.
|
||||||
|
- name: reconcile state + stale
|
||||||
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
|
uses: ./.ceremony-src/actions/labels-reconcile
|
||||||
|
with:
|
||||||
|
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
|
REPO: ${{ github.repository }}
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
|
||||||
run: |
|
if: github.repository == 'heavy-duty/ceremony'
|
||||||
# REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME
|
uses: ./actions/labels-reconcile
|
||||||
# shape on both forges —
|
with:
|
||||||
# POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
|
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
|
||||||
# {"ref": "<branch>", "inputs": {...}} -> 204, empty body
|
env:
|
||||||
# — so this step no longer decides a forge at all. That is why the
|
GH_TOKEN: ${{ github.token }}
|
||||||
# `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are
|
REPO: ${{ github.repository }}
|
||||||
# gone rather than ported: there is nothing left to refuse. Measured
|
- name: reconcile issue flow
|
||||||
# on this instance (Forgejo 8.0.3+gitea-1.22.0) and published in its
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
# own swagger; run 459 was raised this way.
|
uses: ./.ceremony-src/actions/issueflow-reconcile
|
||||||
#
|
env:
|
||||||
# STILL LOUD on failure, per this job's contract: a consumer missing
|
GH_TOKEN: ${{ github.token }}
|
||||||
# the sweep caller, its `bootstrap` input, or `actions: write` must
|
REPO: ${{ github.repository }}
|
||||||
# fail HERE and visibly, not sweep silently never again.
|
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
|
||||||
# NEVER "probably github" (lib/forge.sh). Defaulting an unset
|
if: github.repository == 'heavy-duty/ceremony'
|
||||||
# GITHUB_API_URL to api.github.com would send this forge's dispatch
|
uses: ./actions/issueflow-reconcile
|
||||||
# to GitHub and report success — the same unset-environment guess
|
env:
|
||||||
# #201 just refused for docs-sync. The API root is injected by the
|
GH_TOKEN: ${{ github.token }}
|
||||||
# forge running us; if it is absent we do not know where we are, and
|
REPO: ${{ github.repository }}
|
||||||
# a guess is worse than a red trigger
|
|
||||||
# (@codex-reviewer-andresmgsl, #205 review).
|
|
||||||
api="${GITHUB_API_URL:-}"
|
|
||||||
if [ -z "$api" ]; then
|
|
||||||
echo "::error::labels: the sweep was NOT woken — GITHUB_API_URL is unset, so the forge's API root is unknown. Refusing to guess a forge."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# `gh workflow run` defaulted the ref to the repository's default
|
|
||||||
# branch; REST has no default and 400s without one. Prefer the event
|
|
||||||
# payload, fall back to an API read: on a `pull_request_target` run
|
|
||||||
# GITHUB_REF_NAME is `<n>/merge`, which is not a branch and would
|
|
||||||
# dispatch nothing.
|
|
||||||
branch="${DEFAULT_BRANCH:-}"
|
|
||||||
if [ -z "$branch" ]; then
|
|
||||||
branch="$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \
|
|
||||||
"$api/repos/$GITHUB_REPOSITORY" | jq -r '.default_branch // empty')"
|
|
||||||
fi
|
|
||||||
if [ -z "$branch" ]; then
|
|
||||||
echo "::error::labels: the sweep was NOT woken — could not determine the default branch to dispatch $SWEEP_WORKFLOW on."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
out="$(mktemp)"
|
|
||||||
err="$(mktemp)"
|
|
||||||
trap 'rm -f "$out" "$err"' EXIT
|
|
||||||
# A transport failure is named, not merely propagated. Letting `set
|
|
||||||
# -e` carry curl's own exit code out of the assignment DID fail the
|
|
||||||
# job — the invariant holds — but it failed with a bare status and no
|
|
||||||
# sentence, which is the opposite of this step owning its diagnostic.
|
|
||||||
if ! code="$(curl -sS -o "$out" -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -nc --arg ref "$branch" '{ref: $ref, inputs: {bootstrap: "no"}}')" \
|
|
||||||
"$api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches" \
|
|
||||||
2>"$err")"; then
|
|
||||||
echo "::error::labels: the sweep was NOT woken — the request to $api never completed: $(tr -d '\n' <"$err")"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$code" != "204" ]; then
|
|
||||||
# Own the diagnostic rather than pass the status through. This
|
|
||||||
# Forgejo answers an unknown workflow name — and a bare ref that
|
|
||||||
# does not resolve — with `500` and an EMPTY body, so the raw
|
|
||||||
# status alone sends the reader looking for a server fault that is
|
|
||||||
# not there.
|
|
||||||
echo "::error::labels: the sweep was NOT woken — POST $api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches (ref=$branch) returned HTTP $code: $(tr -d '\n' <"$out")"
|
|
||||||
echo "::error::labels: check that $SWEEP_WORKFLOW exists on $branch, declares a \`bootstrap\` workflow_dispatch input, and that this caller grants \`actions: write\`. An empty 500 body from Forgejo means the workflow name or the ref did not resolve."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
|
|
||||||
|
|
||||||
fork_head:
|
|
||||||
# This Forgejo keeps pull_request_target read-only for fork heads (#241),
|
|
||||||
# so name the deliberately unsupported scope write as well as the deferred
|
|
||||||
# state machine instead of letting a green no-op promise full labelling.
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'pull_request_target' &&
|
|
||||||
github.event.pull_request.head.repo.full_name != github.repository
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: explain deferred fork labels
|
|
||||||
run: >-
|
|
||||||
echo "labels: fork head has a read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads"
|
|
||||||
|
|
|
||||||
34
.github/workflows/refs-guard.yml
vendored
34
.github/workflows/refs-guard.yml
vendored
|
|
@ -1,34 +0,0 @@
|
||||||
name: Refs guard
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Body edits are load-bearing: #200 gained its accidental closing keyword
|
|
||||||
# after the PR opened, with no new commit to wake ordinary CI (#218).
|
|
||||||
pull_request:
|
|
||||||
types: [opened, edited, reopened, synchronize]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
refs-not-closing:
|
|
||||||
# The action is portable (#199): its gather is two REST reads through the
|
|
||||||
# shim plus this repo's own closing-keyword parser, so it produces a real
|
|
||||||
# verdict on either forge. It still refuses by name when it cannot read —
|
|
||||||
# that is its contract, and its contract test.
|
|
||||||
#
|
|
||||||
# #199 removed the forge gate that used to sit here. While the action's
|
|
||||||
# only gather was GraphQL it could do nothing but refuse on Forgejo, and
|
|
||||||
# scheduling a permanently red required check would have blocked every
|
|
||||||
# merge on this forge; a skipped check is a green head, an invented
|
|
||||||
# verdict is not. The gather is REST now, so the job RUNS here and
|
|
||||||
# produces verdicts again.
|
|
||||||
#
|
|
||||||
# Deleting the action's client declaration without deleting this gate
|
|
||||||
# would have left it portable and never scheduled — a guard that passes
|
|
||||||
# by never running, which is this repo's blind-sweep shape wearing a
|
|
||||||
# different hat (@kimi-reviewer-andresmgsl, #198).
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
- uses: ./actions/refs-not-closing
|
|
||||||
22
.github/workflows/release-exercise.yml
vendored
22
.github/workflows/release-exercise.yml
vendored
|
|
@ -94,25 +94,12 @@ jobs:
|
||||||
# consumes the tool end to end instead of hand-writing its output.
|
# consumes the tool end to end instead of hand-writing its output.
|
||||||
# Same shape as test/release-chain.test.sh. The gh stub answers the
|
# Same shape as test/release-chain.test.sh. The gh stub answers the
|
||||||
# one API fact the ceremony path consults (the merged
|
# one API fact the ceremony path consults (the merged
|
||||||
# release-labeled PR) so nothing here talks to a forge.
|
# release-labeled PR) so nothing here talks to GitHub.
|
||||||
#
|
|
||||||
# The stub is gh-shaped, so the facts step below pins
|
|
||||||
# CEREMONY_FORGE=github: since #191 facts.sh selects a backend, and
|
|
||||||
# on a Forgejo runner it would otherwise pick the forgejo backend,
|
|
||||||
# which speaks curl and would walk straight past this stub to the
|
|
||||||
# real instance — reading the exercise's fixture SHA against the
|
|
||||||
# live repository and refusing it. The exercise rehearses the
|
|
||||||
# WIRING; which backend answers is lib/forge.sh's own contract,
|
|
||||||
# covered in test/forge*.test.sh.
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "$RUNNER_TEMP/stub"
|
mkdir -p "$RUNNER_TEMP/stub"
|
||||||
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
|
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# The label read is GET commits/{sha}/pulls — a JSON array (#191).
|
if [ "$1" = api ]; then echo true; exit 0; fi
|
||||||
if [ "$1" = api ]; then
|
|
||||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "gh stub: unexpected call: gh $*" >&2
|
echo "gh stub: unexpected call: gh $*" >&2
|
||||||
exit 97
|
exit 97
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -132,7 +119,7 @@ jobs:
|
||||||
EOF
|
EOF
|
||||||
mkdir changelog.d
|
mkdir changelog.d
|
||||||
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
|
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
|
||||||
printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md
|
printf -- '- The entry this release ships.\n' > changelog.d/42.md
|
||||||
git add VERSION CHANGELOG.md changelog.d
|
git add VERSION CHANGELOG.md changelog.d
|
||||||
git commit -qm "base"
|
git commit -qm "base"
|
||||||
printf '0.7.0\n' > VERSION
|
printf '0.7.0\n' > VERSION
|
||||||
|
|
@ -149,9 +136,6 @@ jobs:
|
||||||
# back to the merge commit's first parent (#1 constraint 10).
|
# back to the merge commit's first parent (#1 constraint 10).
|
||||||
EVENT_BEFORE: ""
|
EVENT_BEFORE: ""
|
||||||
VERSION_SOURCE: file
|
VERSION_SOURCE: file
|
||||||
# The stub above is gh-shaped; pin the backend that uses it.
|
|
||||||
CEREMONY_FORGE: github
|
|
||||||
GITHUB_REPOSITORY: fixture/fixture
|
|
||||||
# release.yml's step verbatim — same invocation, same
|
# release.yml's step verbatim — same invocation, same
|
||||||
# $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the
|
# $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the
|
||||||
# workspace (the one thing a replay cannot inherit).
|
# workspace (the one thing a replay cannot inherit).
|
||||||
|
|
|
||||||
89
.github/workflows/release.yml
vendored
89
.github/workflows/release.yml
vendored
|
|
@ -54,7 +54,7 @@ name: release
|
||||||
# branches: [main]
|
# branches: [main]
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: write # tag ref create + release create + the bump push
|
# contents: write # tag ref create + release create + the bump push
|
||||||
# pull-requests: write # the label read; the bump-fallback PR
|
# pull-requests: write # the label read; the bump-fallback `gh pr create`
|
||||||
# issues: write # --label on that fallback PR rides the issues API
|
# issues: write # --label on that fallback PR rides the issues API
|
||||||
# jobs:
|
# jobs:
|
||||||
# release:
|
# release:
|
||||||
|
|
@ -87,7 +87,7 @@ name: release
|
||||||
# ## The artifact hook (#1 D4)
|
# ## The artifact hook (#1 D4)
|
||||||
#
|
#
|
||||||
# If the consumer carries .github/actions/release-artifact/action.yml, both
|
# If the consumer carries .github/actions/release-artifact/action.yml, both
|
||||||
# doors invoke it — after the tag exists, before the publish — with
|
# doors invoke it — after the tag exists, before `gh release create` — with
|
||||||
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
|
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
|
||||||
# drops there is uploaded as a release asset. Exit non-zero to abort the
|
# drops there is uploaded as a release asset. Exit non-zero to abort the
|
||||||
# release. No hook → no assets.
|
# release. No hook → no assets.
|
||||||
|
|
@ -129,7 +129,7 @@ env:
|
||||||
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
|
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
|
||||||
# shared logic arrives as script files via checkout, not as inner `uses:`
|
# shared logic arrives as script files via checkout, not as inner `uses:`
|
||||||
# references.
|
# references.
|
||||||
CEREMONY_SELF_REF: "0.6.3"
|
CEREMONY_SELF_REF: "0.3.0"
|
||||||
VERSION_SOURCE: ${{ inputs.version-source }}
|
VERSION_SOURCE: ${{ inputs.version-source }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -205,40 +205,24 @@ jobs:
|
||||||
fi
|
fi
|
||||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||||
cat "$RUNNER_TEMP/notes.md"
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
- name: preflight — resume this merge, refuse every other collision
|
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
|
||||||
id: preflight
|
|
||||||
if: steps.decide.outputs.ceremony == 'yes'
|
if: steps.decide.outputs.ceremony == 'yes'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
VER: ${{ steps.facts.outputs.ver }}
|
VER: ${{ steps.facts.outputs.ver }}
|
||||||
MERGE_SHA: ${{ github.sha }}
|
# What makes a re-run of a completed ceremony refuse instead of
|
||||||
# The pure table in lib/preflight.sh distinguishes a stranded run of
|
# clobber, and what catches a manual tag racing the merge.
|
||||||
# this door from a completed release or a tag at another commit (#273).
|
|
||||||
run: |
|
run: |
|
||||||
tag_read_rc=0
|
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
|
||||||
tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$?
|
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
|
||||||
case "$tag_read_rc" in
|
exit 1
|
||||||
0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;;
|
fi
|
||||||
2) tag_shas="" ;;
|
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
|
||||||
*)
|
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
||||||
echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$CEREMONY_DIR/lib/forge.sh"
|
|
||||||
forge_select ""
|
|
||||||
if ! released="$(forge_release_exists "$VER")"; then
|
|
||||||
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$CEREMONY_DIR/lib/preflight.sh"
|
|
||||||
out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
|
|
||||||
printf '%s\n' "$out"
|
|
||||||
printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT"
|
|
||||||
- name: tag the merge commit — same job as the publish, on purpose
|
- name: tag the merge commit — same job as the publish, on purpose
|
||||||
if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes'
|
if: steps.decide.outputs.ceremony == 'yes'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
VER: ${{ steps.facts.outputs.ver }}
|
VER: ${{ steps.facts.outputs.ver }}
|
||||||
|
|
@ -247,10 +231,8 @@ jobs:
|
||||||
# the tag door cannot double-fire off this tag — and this job is
|
# the tag door cannot double-fire off this tag — and this job is
|
||||||
# the only chance to publish (the sources' central comment).
|
# the only chance to publish (the sources' central comment).
|
||||||
run: |
|
run: |
|
||||||
# shellcheck source=/dev/null
|
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||||
. "$CEREMONY_DIR/lib/forge.sh"
|
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA"
|
||||||
forge_select ""
|
|
||||||
forge_tag_create "$VER" "$MERGE_SHA"
|
|
||||||
- name: artifact hook — the consumer's own release-artifact action
|
- name: artifact hook — the consumer's own release-artifact action
|
||||||
# Runs after the tag exists, before the publish (#1 D4). The local
|
# Runs after the tag exists, before the publish (#1 D4). The local
|
||||||
# path resolves in the consumer checkout at the workspace root —
|
# path resolves in the consumer checkout at the workspace root —
|
||||||
|
|
@ -271,10 +253,9 @@ jobs:
|
||||||
for f in "$RELEASE_ASSETS_DIR"/*; do
|
for f in "$RELEASE_ASSETS_DIR"/*; do
|
||||||
if [ -e "$f" ]; then assets+=("$f"); fi
|
if [ -e "$f" ]; then assets+=("$f"); fi
|
||||||
done
|
done
|
||||||
# shellcheck source=/dev/null
|
gh release create "$VER" --verify-tag --title "$VER" \
|
||||||
. "$CEREMONY_DIR/lib/forge.sh"
|
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
|
||||||
forge_select ""
|
"${assets[@]}"
|
||||||
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
|
|
||||||
# The post-release bump, folded into the release act (the sources'
|
# The post-release bump, folded into the release act (the sources'
|
||||||
# operator decision: a mechanical one-liner deserves no PR of its
|
# operator decision: a mechanical one-liner deserves no PR of its
|
||||||
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
|
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
|
||||||
|
|
@ -313,13 +294,10 @@ jobs:
|
||||||
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
||||||
git checkout -b "chore/bump-$next"
|
git checkout -b "chore/bump-$next"
|
||||||
git push origin "chore/bump-$next"
|
git push origin "chore/bump-$next"
|
||||||
# shellcheck source=/dev/null
|
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
|
||||||
. "$CEREMONY_DIR/lib/forge.sh"
|
--title "chore: bump main to $next" \
|
||||||
forge_select ""
|
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
|
||||||
forge_pr_create "chore/bump-$next" main \
|
--label release
|
||||||
"chore: bump main to $next" \
|
|
||||||
"The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
|
|
||||||
release
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
release-on-tag:
|
release-on-tag:
|
||||||
|
|
@ -372,22 +350,6 @@ jobs:
|
||||||
fi
|
fi
|
||||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||||
cat "$RUNNER_TEMP/notes.md"
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
- name: no published release may exist
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
VER: ${{ steps.assert.outputs.ver }}
|
|
||||||
run: |
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$CEREMONY_DIR/lib/forge.sh"
|
|
||||||
forge_select ""
|
|
||||||
if ! exists="$(forge_release_exists "$VER")"; then
|
|
||||||
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$exists" = yes ]; then
|
|
||||||
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- name: artifact hook — the consumer's own release-artifact action
|
- name: artifact hook — the consumer's own release-artifact action
|
||||||
# After the tag exists (it fired this door), before the publish —
|
# After the tag exists (it fired this door), before the publish —
|
||||||
# the same contract as the merge door's twin step.
|
# the same contract as the merge door's twin step.
|
||||||
|
|
@ -404,7 +366,6 @@ jobs:
|
||||||
for f in "$RELEASE_ASSETS_DIR"/*; do
|
for f in "$RELEASE_ASSETS_DIR"/*; do
|
||||||
if [ -e "$f" ]; then assets+=("$f"); fi
|
if [ -e "$f" ]; then assets+=("$f"); fi
|
||||||
done
|
done
|
||||||
# shellcheck source=/dev/null
|
gh release create "$VER" --verify-tag --title "$VER" \
|
||||||
. "$CEREMONY_DIR/lib/forge.sh"
|
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
|
||||||
forge_select ""
|
"${assets[@]}"
|
||||||
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
|
|
||||||
|
|
|
||||||
58
.github/workflows/self-labels-sweep.yml
vendored
58
.github/workflows/self-labels-sweep.yml
vendored
|
|
@ -1,58 +0,0 @@
|
||||||
name: labels-sweep
|
|
||||||
# Ceremony's own sweep caller (#209) — self-labels.yml's detached half,
|
|
||||||
# wearing the same local-`uses:` deviation and the same warning: consumers
|
|
||||||
# must NEVER copy the local form (it rides main, unpinned — correct only
|
|
||||||
# for the repo that IS the source). Consumers write:
|
|
||||||
# uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
|
|
||||||
on:
|
|
||||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
|
||||||
# when no other engine drives board state: the cron is then the sweep's ONLY
|
|
||||||
# wake for a review verdict landing (there is no
|
|
||||||
# pull_request_review trigger on the labels caller), blocker:ci-red set or
|
|
||||||
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
|
|
||||||
# ANOTHER PR merges under this one, and the time-based stale / 48h
|
|
||||||
# claim-reclaim, plus every state, blocker, and handoff transition for a
|
|
||||||
# fork-headed PR on this Forgejo because its pull_request_target token is
|
|
||||||
# read-only (#241). The sweep never applies path-derived scope labels. Issue
|
|
||||||
# events and same-repository PR events carry the rest in seconds, one
|
|
||||||
# trigger-job dispatch away. Hourly trades ≤1h of latency on the scheduled
|
|
||||||
# classes while cutting nominal scheduled sweeps from four an hour to one at
|
|
||||||
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
|
||||||
# discovery path. If another engine writes some of those transitions, only
|
|
||||||
# the classes with no other writer bound the cadence; relax it only as that
|
|
||||||
# list shrinks.
|
|
||||||
schedule: [{cron: "0 * * * *"}]
|
|
||||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
|
||||||
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
|
|
||||||
# caller did before #209. The reusable's trigger job wakes this workflow
|
|
||||||
# with bootstrap=no on every issue and same-repository PR event — an
|
|
||||||
# event-woken sweep must not re-upsert ~20 labels each time — so declaring
|
|
||||||
# this input is part of the
|
|
||||||
# caller contract: a dispatch naming an undeclared input is refused, and
|
|
||||||
# the trigger job goes loudly red.
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
bootstrap:
|
|
||||||
description: Bootstrap the label taxonomy before sweeping
|
|
||||||
type: choice
|
|
||||||
options: ["yes", "no"]
|
|
||||||
default: "yes"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
checks: read # mergeability/check-rollup read for PR state
|
|
||||||
statuses: read # commit-status rollup read for PR state
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
jobs:
|
|
||||||
sweep:
|
|
||||||
# pr_workflow_name keeps its default: ceremony's PR-facing caller is
|
|
||||||
# named `labels` (self-labels.yml).
|
|
||||||
uses: ./.github/workflows/labels-sweep.yml
|
|
||||||
with:
|
|
||||||
# The dispatch input crosses the workflow_call boundary HERE, or not
|
|
||||||
# at all: on this instance the called workflow did not see this
|
|
||||||
# caller's event inputs implicitly (ceremony#215), so declare-and-pass
|
|
||||||
# is the only channel used. On `schedule` the top-level context is
|
|
||||||
# empty, and empty maps to "no" EXPLICITLY — a cron that bootstraps is
|
|
||||||
# the failure kimi named before it could exist.
|
|
||||||
bootstrap: ${{ inputs.bootstrap || 'no' }}
|
|
||||||
45
.github/workflows/self-labels.yml
vendored
45
.github/workflows/self-labels.yml
vendored
|
|
@ -4,54 +4,23 @@ name: labels
|
||||||
# same warning: consumers must NEVER copy the local form (it rides main,
|
# same warning: consumers must NEVER copy the local form (it rides main,
|
||||||
# unpinned — correct only for the repo that IS the source). Consumers write:
|
# unpinned — correct only for the repo that IS the source). Consumers write:
|
||||||
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
||||||
#
|
|
||||||
# Since #209 this caller carries the PR/issue event surface only. The
|
|
||||||
# reconcile sweep no longer rides these runs — the reusable's trigger job
|
|
||||||
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
|
|
||||||
# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
|
|
||||||
# same-repository PR events below still yield a sweep within seconds, one
|
|
||||||
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
|
|
||||||
# so their successful labels run leaves state, blocker, and handoff
|
|
||||||
# reconciliation to the hourly sweep; path-derived scope labels are not
|
|
||||||
# applied to those heads (#241).
|
|
||||||
on:
|
on:
|
||||||
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||||
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
# unassigned, which feed validation and the 48h claim clock (caught within one
|
|
||||||
# cadence) and were the dominant issues-churn source. Kept: `opened` (the
|
|
||||||
# mint→needs-triage check, issueflow's opened-only path), `closed` (the
|
|
||||||
# blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the
|
|
||||||
# `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179),
|
|
||||||
# `reopened` (a closed issue re-entering the queue wearing labels derived when
|
|
||||||
# it closed). The must-fail in #199 is exactly "a queue-state transition waits
|
|
||||||
# on the schedule when an event could have carried it", so edited/reopened stay
|
|
||||||
# on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so
|
|
||||||
# this does not touch the handoff.
|
|
||||||
issues:
|
issues:
|
||||||
types: [opened, closed, edited, reopened]
|
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
# These carry the head/draft/review facts the sweep derives state:* from.
|
# review_requested/review_request_removed wake the sweep that clears (or
|
||||||
# Same-repository heads wake that sweep in seconds; fork heads cannot write
|
# restores) blocker:unrequested — without them the one event that makes
|
||||||
# with this Forgejo's read-only token, so state, blocker, and handoff
|
# the label false could not clear it, and a quiet repo wore the red flag
|
||||||
# reconciliation waits for the scheduled cadence. The sweep does not apply
|
# until the advisory cron (#137).
|
||||||
# path-derived scope labels to those heads (#241).
|
|
||||||
# labeled/unlabeled are the same-repository handoff wake — the author's
|
|
||||||
# optimistic state:needs-human write, confirmed or corrected here in
|
|
||||||
# seconds (#11); synchronize re-derives on every push; review_requested/
|
|
||||||
# review_request_removed clear (or restore) blocker:unrequested on that
|
|
||||||
# same instant path (#137).
|
|
||||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
checks: read # mergeability/check-rollup read for PR state
|
checks: read # mergeability/check-rollup read for PR state
|
||||||
statuses: read # commit-status rollup read for PR state
|
statuses: read # commit-status rollup read for PR state
|
||||||
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
|
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
jobs:
|
jobs:
|
||||||
labels:
|
labels:
|
||||||
uses: ./.github/workflows/labels.yml
|
uses: ./.github/workflows/labels.yml
|
||||||
with:
|
|
||||||
# Dogfood filename deviation only — consumers keep the default,
|
|
||||||
# labels-sweep.yml, and pass nothing.
|
|
||||||
sweep_workflow: self-labels-sweep.yml
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# The upstream commit this tree carries (docs/UPSTREAM-SYNC.md).
|
|
||||||
# Full 40-char SHA, immutable: captured at fetch, merged, then recorded —
|
|
||||||
# NOT re-read from gh/main later, which moves. Read by
|
|
||||||
# test/upstream-delta.test.sh, which REFUSES when the object is absent
|
|
||||||
# rather than calling it unverifiable.
|
|
||||||
# github.com/heavy-duty/ceremony
|
|
||||||
8c3a4d1dee2bdb5ac06a632a285bb65ab2615214
|
|
||||||
10
AGENTS.md
10
AGENTS.md
|
|
@ -14,7 +14,7 @@ reviewer here"). That one word is your whole onboarding:
|
||||||
|
|
||||||
| you are the… | read | your job in one line |
|
| you are the… | read | your job in one line |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||||
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ are the shared state machine, and misusing one lies to every other agent on
|
||||||
the board.
|
the board.
|
||||||
|
|
||||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||||
asked to implement an issue → builder; asked to process proposals or the
|
asked to implement an issue → builder; asked to process discussions or the
|
||||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||||
across roles in one session: a builder reviewing its own PR, or a reviewer
|
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||||
pushing fixes, breaks the separation the pipeline depends on.
|
pushing fixes, breaks the separation the pipeline depends on.
|
||||||
|
|
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
|
||||||
## The pipeline you are part of
|
## The pipeline you are part of
|
||||||
|
|
||||||
```
|
```
|
||||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||||
```
|
```
|
||||||
|
|
||||||
Two rules bind every role:
|
Two rules bind every role:
|
||||||
|
|
||||||
- **Only triage mints work issues.** Found work? File or extend a proposal.
|
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||||
a merge button.
|
a merge button.
|
||||||
|
|
||||||
|
|
|
||||||
448
BUILDER.md
448
BUILDER.md
|
|
@ -6,249 +6,191 @@ triage bug, and the move is to say so on the issue, not to guess.
|
||||||
|
|
||||||
## Picking
|
## Picking
|
||||||
|
|
||||||
- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an
|
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
|
||||||
`epic` (epics organize; their children are the work). Inside an epic take
|
never an `epic` (epics organize; their children are the work).
|
||||||
the earliest unblocked unclaimed child, otherwise the issue that unblocks
|
- Respect dependency order: inside an epic, take the earliest unblocked
|
||||||
the most work; where a repo adopts version epics,
|
unclaimed child. Between epics and strays, prefer the issue that unblocks
|
||||||
[RELEASES.md](RELEASES.md) governs among window members.
|
the most other work.
|
||||||
- **Your own red head outranks a new claim**: repair a failing check at your
|
- **One build at a time.** You hold at most one issue on which you are
|
||||||
PR's head before claiming another issue (#163). Red and green here are the
|
writing or revising a deliverable — finish or release that work before
|
||||||
review round's ruled terms: cancelled, stale, or unreported — every entry
|
starting new work. The rule counts build work in flight, not claims: a
|
||||||
at the head cancelled — is not green; skipped or neutral is. Record the
|
claim does not consume the slot while it is **parked**, meaning the next
|
||||||
check and its failure class; rerun a clearly retryable infrastructure
|
move belongs to someone else. Exactly five shapes qualify:
|
||||||
failure unchanged; treat a branch failure as an ordinary fix round,
|
1. the issue carries `needs-ruling`, its escalation names a decider, and
|
||||||
worklog and all; leave evidence where a rerun cannot start or the cause is
|
its `Blocked:` line stops the remaining work;
|
||||||
unclear; never rerun a deterministic failure without a corrective commit;
|
2. the deliverable is in a review round where every outstanding verdict
|
||||||
hand off once green with current-head approvals. Such a PR is **never
|
belongs to someone else — either the round is awaiting its first
|
||||||
parked**, whatever the verdict state says; how the engine detects a red
|
verdicts, or it was answered whole and the non-approvers re-requested
|
||||||
head is crew's to describe.
|
(the review round, steps 1–2). This is the *live* round; shape 4 is
|
||||||
- **One build at a time**: one issue on which you are writing or revising a
|
the *passed* one — they are sequential and do not overlap;
|
||||||
deliverable, finished or released before you start more. The rule counts
|
3. every remaining acceptance criterion is operator-owned, stated as such
|
||||||
work in flight, not claims — a **parked** claim, whose next move is
|
by triage on the issue;
|
||||||
someone else's, does not hold the slot. Five shapes park:
|
4. the deliverable is **handed off** — the round passed, no `blocker:*`
|
||||||
1. `needs-ruling` is set, the escalation names a decider, and its
|
stands, and you set `state:needs-human` per Handoff (below). The
|
||||||
`Blocked:` line stops the rest;
|
remaining move is the human's merge.
|
||||||
2. a **live** review round holds it, every outstanding verdict someone
|
5. the claim is **held by directive** — triage or the operator has told
|
||||||
else's — awaiting first verdicts, or answered whole with the owed
|
you to stop, the direction names what the hold waits on, and that thing
|
||||||
re-requests posted, by head and not by verdict (steps 1–2). A red check
|
is not yours to move. This is not "waiting for a good moment": somebody
|
||||||
at the head takes it out of this shape: the next move is yours;
|
else has decided the work must not proceed, and only they end it.
|
||||||
3. every remaining acceptance criterion is operator-owned, stated so by
|
And it ends the same way it started: **on the labels.** When the queue
|
||||||
triage on the issue. **An operator-owned remainder parks the claim and
|
labels and any prose — an issue body header, a triage comment, an
|
||||||
never the handoff**: this shape is reached only from the far side of
|
operator's comment — disagree about whether a hold stands, the most
|
||||||
shape 4, because it is the state finishing the work puts you in and
|
recent queue-label event by the hold's owner governs, and the prose is
|
||||||
would otherwise excuse the handoff it should follow (#336);
|
stale until someone corrects it. So before standing down *or* standing
|
||||||
4. it is **handed off** — round passed, no `blocker:*` standing,
|
up on a hold, read the issue's **label events**
|
||||||
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its
|
||||||
and 4 are sequential and never overlap;
|
comments: an operator may lift by label alone, and on 2026-07-24 did,
|
||||||
5. the claim is **held by directive** — triage or the operator stopped the
|
twice, on [#149](https://github.com/heavy-duty/ceremony/issues/149)
|
||||||
work, named what the hold waits on, and only they end it. A hold ends
|
and [#151](https://github.com/heavy-duty/ceremony/issues/151). Acting
|
||||||
as it started, **on the labels**: where labels and prose disagree, the
|
on the labels against stale prose, say so in the claim — name the
|
||||||
most recent queue-label event by the hold's owner governs, and an
|
events you read, their timestamps and their actor, and invite the
|
||||||
operator may lift by label alone (#149, #151). So read the label events
|
correction if the read is wrong;
|
||||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the
|
[the 14:11:45Z claim on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070781295)
|
||||||
comments, before standing down *or* up, and say in the claim which you
|
is the exemplar. Refusing is not a resting place either:
|
||||||
read, their timestamps and their actor. Where they do not resolve the
|
[*"I am not claiming through that contradiction"*](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070776624)
|
||||||
contradiction, say so and take the next `ready` issue; refusing is no
|
was a correct instinct and an incomplete move — the next step is to
|
||||||
resting place.
|
read the events, state what they say, and then claim or stand down on
|
||||||
Not parked: waiting on yourself, on CI (a red head is yours; a pending one
|
that, or, if the events genuinely do not resolve it, say so on the
|
||||||
resolves without you), or for a good moment. An issue you stopped working
|
issue and pick the next `ready` issue rather than idling on this one.
|
||||||
on is abandoned — unassign and restore `ready`. Parked claims are held
|
Not parked — these are what the rule defends against: waiting on
|
||||||
beside the one active build (#15, #16, #73).
|
yourself, waiting on CI, or waiting for a good moment. An issue you have
|
||||||
|
simply stopped working on is not parked either — that is abandonment,
|
||||||
|
and its move is unchanged: unassign and restore `ready` (Claiming,
|
||||||
|
below).
|
||||||
|
The 2026-07-23 board is why the rule counts work and not claims: one
|
||||||
|
builder correctly held
|
||||||
|
[#15](https://github.com/heavy-duty/ceremony/issues/15) (`offsite`,
|
||||||
|
round answered whole, one verdict outstanding) and
|
||||||
|
[#16](https://github.com/heavy-duty/ceremony/issues/16) (`needs-ruling`
|
||||||
|
hard block, triage said hold) parked beside the one active build,
|
||||||
|
[#73](https://github.com/heavy-duty/ceremony/issues/73).
|
||||||
|
|
||||||
## Claiming
|
## Claiming
|
||||||
|
|
||||||
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||||
starting. The claim promises a draft PR soon: a claim with no PR and no
|
starting. The claim is a promise of a draft PR soon — a claim with no PR
|
||||||
activity is what the staleness sweep reclaims, unless `offsite` records
|
and no activity is what the staleness sweep reclaims unless `offsite`
|
||||||
that its PR lives in another repo.
|
records that its PR lives in another repository.
|
||||||
- **A park is declared, never inferred.** Comment naming what the claim
|
- **A park is declared, never inferred.** When your claim enters a parked
|
||||||
waits on and who owns the next move — no new label; the comment is the
|
shape (Picking, above), say so in a comment on that issue, naming what it
|
||||||
activity the reclaim clock reads, as for `needs-ruling` (#52) and
|
waits on and who owns the next move. No new label: the comment is
|
||||||
`offsite` (#68). Shape 4 is exempt: the handoff comment and
|
activity, so it feeds the same reclaim clock the `needs-ruling`
|
||||||
`state:needs-human` already say both.
|
([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite`
|
||||||
- **A declaration stands until the park's facts change**, so a resumption
|
([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions
|
||||||
finding nothing changed posts nothing (#177). Each change owes one comment
|
already guard — a parked claim nobody can name is an abandoned one.
|
||||||
— the wait resolves or changes hands, the shape changes, the claim
|
Shape 4 alone is exempt from the separate comment: the handoff round
|
||||||
unparks. A parked claim with **no open PR** still feeds the 48-hour
|
summary plus the `state:needs-human` write *is* its declaration — both
|
||||||
reclaim clock, so refresh the declaration before it closes; that is a
|
halves are already there, what the claim waits on (the merge) and who
|
||||||
park's only repeat.
|
owns the next move (the human), and both are visible to any scan as a
|
||||||
- **Pick up `attention` before anything else**: post a short pickup comment
|
`labeled` event with the summary beside it. No second comment is owed
|
||||||
and remove the label, which is the ack. A demand on a parked claim is
|
on the issue. Every other shape still declares as above.
|
||||||
usually its unpark, so take the slot back — unless the demand *is* the
|
- **Pick up `attention` before anything else.** On your claim, first post a
|
||||||
park, the pickup comment then doubling as the declaration.
|
short pickup comment and remove `attention`; the removal is the ack. A
|
||||||
- **A directed hold keeps its bookkeeping visible.** The PR carries
|
demand on a parked claim is usually its unpark, so take the slot back under
|
||||||
`blocked` with a comment naming what it waits on; the issue stays
|
the existing rule below rather than leaving the demand parked. A demand
|
||||||
`claimed` and carries `attention` until the builder acks. Nobody unassigns
|
that *is* the park is different: the pickup comment is the declaration,
|
||||||
it, and the 48-hour reclaim does not fire while the claim has an open PR.
|
so one comment does both jobs, and the demand does not take the slot back.
|
||||||
- **Unparking is a claim like any other** and takes the slot: if you are
|
- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked`
|
||||||
active elsewhere, finish or release that work first and say which on both
|
with a comment naming what it waits on; the issue stays `claimed` and
|
||||||
issues. No machinery counts claims per builder, and none should be built
|
carries `attention` until the builder acknowledges it. Nobody unassigns
|
||||||
expecting this section to have specified one.
|
the issue, and the 48-hour reclaim does not fire because the claim has an
|
||||||
- **Abandoning is fine; ghosting is not.** Say where you got to, push the
|
open PR. Unparking follows the existing rule below.
|
||||||
branch if it holds anything useful, unassign, restore `ready`.
|
- **Unparking is a claim like any other.** When the wait ends, the parked
|
||||||
|
issue is work again and takes the slot. If you are already active
|
||||||
|
elsewhere, finish or release that work first, and say which you did on
|
||||||
|
both issues — the slot is still one. Nothing counts claims per builder
|
||||||
|
and no reconciler path enforces any of this: `claim_decision()` sees one
|
||||||
|
issue at a time by construction, and no such machinery should be built
|
||||||
|
expecting it to have been specified here. The discipline is the
|
||||||
|
declaration, not a counter.
|
||||||
|
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
|
||||||
|
push the branch if it holds anything useful, unassign, and restore
|
||||||
|
`ready`.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||||
body. Drafts are invisible to the panel on purpose: that phase is yours.
|
body. `Closes #N` does not cross repos: when the PR is in a different repo
|
||||||
- **`Closes #N` does not cross repos.** A PR in a different repo from its
|
from its authorizing issue, use `Part of <owner>/<repo>#N` instead, and
|
||||||
issue says `Part of <owner>/<repo>#N`, sets `offsite`, and comments the
|
in the same step set `offsite` and comment on that issue with the draft PR
|
||||||
draft link on that issue in the same step; triage closes that issue by
|
link as soon as the draft opens.
|
||||||
hand once its criteria are met, the builder reporting there whether the PR
|
Triage closes the authorizing issue by hand when its acceptance criteria
|
||||||
merged or closed and clearing `offsite` in the same comment. The
|
are met; at that handoff the builder reports whether the cross-repo PR
|
||||||
cross-repo merge never closes the authorizing issue (#13, #16).
|
merged or closed and clears `offsite` in the same comment. The cross-repo
|
||||||
- **`Closes #N` does not survive a post-merge criterion.** Where the issue
|
merge never closes the authorizing issue. This codifies the linkage
|
||||||
body says a criterion can only be checked after the merge — a workflow
|
builders already used on rig#112 and ceremony #13/#16 rather than adding a
|
||||||
trigger proved live, a released artifact, anything whose subject does not
|
new review obligation.
|
||||||
exist until the change is on the base branch — the same-repo PR says
|
`Closes #N` also does not survive a post-merge criterion: when the issue's
|
||||||
`Refs #N`; the issue goes `post-merge` at the merge, the builder walks
|
body states that an acceptance criterion can only be checked after the
|
||||||
away, and triage owns verification and closure on the evidence, returning
|
merge — a live proof of a workflow trigger, a released-artifact check,
|
||||||
the issue to `ready` or minting a fresh one where corrective work is
|
anything whose subject does not exist until the change is on the base
|
||||||
needed — claimable by any builder from current `main`, the original having
|
branch — the same-repo PR uses `Refs #N` instead, and triage closes the
|
||||||
no special standing. The issue body says so — you never judge which
|
issue by hand on the evidence, exactly as it does for cross-repo work.
|
||||||
qualify — and absent it `Closes #N` is the default (#151).
|
The issue body is what says so; you never judge which issues qualify, and
|
||||||
- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`,
|
absent that instruction `Closes #N` remains the default. The exception was
|
||||||
`closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`)
|
bought the hard way: #143 carried `Closes #137` as doctrine then required,
|
||||||
immediately before `#N` anywhere in the body, including the sentence
|
and the merge closed #137 with its post-merge criterion unmet (#151).
|
||||||
explaining why the PR does not close it: GitHub reads the body by
|
Drafts are invisible to the reviewer panel on
|
||||||
adjacency, not intent, and a code span does not protect the phrase (#200,
|
purpose — the draft phase is yours.
|
||||||
#218). Put the number first (`#N is closed by hand`) or omit it.
|
- **The issue's acceptance criteria are your definition of done.** Reproduce
|
||||||
- **The issue's acceptance criteria are your definition of done**: reproduce
|
them as a checklist in the PR body and check them honestly as you go. If
|
||||||
them as a checklist in the PR body and check them honestly. One that turns
|
one turns out to be wrong or unreachable, say so on the issue and get it
|
||||||
out wrong or unreachable goes back to triage to be amended, never silently
|
amended by triage — do not silently ship less than the issue says.
|
||||||
shipped short.
|
- Every behavior change writes one fragment, `changelog.d/<issue>.md`,
|
||||||
- **Every behavior change writes one fragment**, `changelog.d/<issue>.md`
|
named for the authorizing issue (`<repo>-<issue>.md` when the work is
|
||||||
named for the authorizing issue (`<repo>-<issue>.md` cross-repo): the
|
cross-repo) — the exact prose that will be published, nothing else: `- `
|
||||||
prose to be published and nothing else — `- ` bullets, plus in a grouped
|
bullets, and in a grouped repo the `### Added` / `### Changed` /
|
||||||
repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only
|
`### Fixed` headings inside the fragment, creating a rarer kind only when
|
||||||
where a change genuinely is one. An entry is at most 300 characters, so a
|
a change genuinely is one. Never edit `CHANGELOG.md` for an entry — the
|
||||||
long change ships several short ones (wrapping over continuation lines is
|
release PR assembles the section from the fragments (#112); the monotonic
|
||||||
free), and it **ends with its issue citation**: a parenthesised group of
|
guard still refuses anything that deletes a shipped heading.
|
||||||
`#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and
|
|
||||||
nothing after — `(#262).`, `(#236, #250).` — which need not name the
|
|
||||||
fragment's own issue, the filename carrying it. The guard reds a long
|
|
||||||
entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the
|
|
||||||
release PR assembles the section from fragments (#112), and the monotonic
|
|
||||||
guard refuses anything deleting a shipped heading.
|
|
||||||
- Follow the repo's conventions file and match the code you touch. Tests are
|
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||||
not optional: the issue's test plan is the floor, not the ceiling.
|
not optional: the issue's test plan is the floor, not the ceiling.
|
||||||
- **A write-capable job gets a repo-owned script, not a third-party
|
|
||||||
action.** Where the token can write (`packages: write`, `contents: write`,
|
|
||||||
`id-token: write`, deploy secrets), default to a script a test can drive;
|
|
||||||
a third-party action there needs an established publisher and a
|
|
||||||
full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and
|
|
||||||
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
|
||||||
(#216).
|
|
||||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||||
Adjacent problems go to a proposal, or a comment on the relevant issue;
|
Adjacent problems you discover go to a **discussion** (or a comment on the
|
||||||
you do not mint work issues — nobody but triage does — and you do not fix
|
relevant issue), where triage will do its job. You do not mint issues —
|
||||||
drive-by findings in the same PR.
|
nobody but triage does — and you do not fix drive-by findings in the same
|
||||||
|
PR; a reviewer cannot converge on a moving, widening target.
|
||||||
|
|
||||||
## The review round
|
## The review round
|
||||||
|
|
||||||
(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts
|
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
|
||||||
such as the panel roster live in that repo's own CONTRIBUTING.)
|
panel roster and any repo-specific flow notes live in that repo's own
|
||||||
|
CONTRIBUTING; everything below is the shared flow.)
|
||||||
|
|
||||||
1. Mark ready-for-review; request **the whole panel**: the PR repo's
|
1. Mark ready-for-review; request **the whole panel**. The panel is the roster
|
||||||
`panel[<your-login>]=` line if it defines one, else its `panel=` line,
|
of the repo the **PR** is in, minus you — never the roster of the repo the
|
||||||
minus the author (#224) — never the roster of the repo the issue is in.
|
issue is in. The PR repo's `.github/labels.conf` `panel=` line is the
|
||||||
That repo's `.github/labels.conf` governs over its CONTRIBUTING roster,
|
machine's answer; its CONTRIBUTING roster is the human-readable answer,
|
||||||
being what the state machine reads; where it names no roster, ask triage
|
and `panel=` governs if they disagree because that is what the state
|
||||||
on the authorizing issue rather than guess. An off-panel reviewer may be
|
machine reads. If the PR repo names no roster, ask triage on the
|
||||||
requested, said to be advisory and not required.
|
authorizing issue before marking ready-for-review; do not guess. You may
|
||||||
|
request an off-panel reviewer, but say that their verdict is advisory and
|
||||||
**A review request requires a green check at the head**, whether or not
|
does not become required. On rig#112 this distinction mattered: requesting
|
||||||
an engine enforces it: a red check is the author's own signal, so fix it
|
codex and grok was correct for rig's panel even though ceremony's bench was
|
||||||
and push, then request. The one exception is a failure genuinely outside
|
larger, and the doctrine had not said which roster governed.
|
||||||
the PR — a runner outage, a flaky dependency, a failure already on the
|
|
||||||
default branch — and only where the request says so and names the
|
|
||||||
evidence ("the same job fails identically on `origin/main` at `<sha>`");
|
|
||||||
silence about a red check is what is prohibited, and an argued exception
|
|
||||||
shifts the burden to the author.
|
|
||||||
|
|
||||||
*Green* is a ruled term (operator, 2026-07-27), read in two steps.
|
|
||||||
**First take the check's word at this head**: its newest entry by start
|
|
||||||
time — not completion, a cancelled run outliving its replacement's start
|
|
||||||
— and never a `CANCELLED` entry while the same check has a non-cancelled
|
|
||||||
one there. A check whose entries at the head are all cancelled has not
|
|
||||||
reported at all and is not green — a collapse, not a new class, and the
|
|
||||||
gate partitions alike, dropping a cancelled entry only where a
|
|
||||||
non-cancelled survivor remains and leaving an all-cancelled context
|
|
||||||
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
|
||||||
`status`**, which can disagree with it (#259). No conclusion is not
|
|
||||||
green: a configured run in progress is waited on, and waiting is
|
|
||||||
compliance, not a stall — the wait is the **request's**, and never a
|
|
||||||
reason to withhold the declaration that a round was answered (step 2).
|
|
||||||
Cancelled or stale is not green, *stale* being a
|
|
||||||
superseded head's check, which a head-scoped rollup never shows. Skipped
|
|
||||||
or neutral is green, those being deliberate "passed / not applicable"
|
|
||||||
conclusions. No checks configured is green — the third ruled case, not an
|
|
||||||
argued exception, so the request goes out at once with no evidence owed;
|
|
||||||
that never covers nothing-answered-yet, and the machine partitions alike,
|
|
||||||
admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the
|
|
||||||
line are asymmetric: a false green spends a three-reviewer round, a false
|
|
||||||
red one author session. What the machine drops from the rollup before
|
|
||||||
grading is crew's to describe.
|
|
||||||
2. **Wait for every verdict, then answer the round whole** — one reply
|
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||||
covering every point, stating what changed and what was verified. That
|
covering every point, then push the fixes, then re-request exactly the
|
||||||
reply is the written record: the engine mirrors it under the PR body's
|
reviewers who did not approve. Prefer verification over argument: when a
|
||||||
**Round log**, newest last and marked with the round's head, which makes
|
reviewer doubts behavior, add the test that settles it.
|
||||||
a retry a no-op; you owe the reply and no body edit, and a round answered
|
|
||||||
without one is recorded as such and never blocks handoff. Then push the
|
|
||||||
fixes and re-request **by head, not by verdict**. A push makes every
|
|
||||||
approval stale — an approval is of a specific tree, and the handoff
|
|
||||||
predicate counts only approvals at the current head — so **every panelist
|
|
||||||
is re-requested, approvers included**; one left un-re-requested can never
|
|
||||||
approve the tree you shipped (#26, #39). Only where the head did not move
|
|
||||||
— answered with argument or evidence, nothing pushed — do you re-request
|
|
||||||
just the non-approvers; the engine absorbs a re-request at an unchanged
|
|
||||||
head, and its mechanism is crew's to describe (#94). **The re-request
|
|
||||||
carries the same green-check-at-head precondition**, argued exception
|
|
||||||
included: a fix push whose check comes up red is your next fix, not the
|
|
||||||
panel's. **Where an engine mediates the request, that precondition binds
|
|
||||||
the engine's act and not yours**: declaring a round answered is not
|
|
||||||
requesting the panel, so declare it as soon as the round's fixes are
|
|
||||||
pushed and stop. The engine holds the request while the head is pending
|
|
||||||
or red, so an early declaration cannot produce an early request while a
|
|
||||||
withheld one is indistinguishable from a session that died (#330).
|
|
||||||
**Never wait on an event you have no wake for** — where the engine is
|
|
||||||
what observes the check settling, the wait is the engine's to keep
|
|
||||||
(#330). **Never block on a producer you cannot prove alive either**:
|
|
||||||
where a job signals its own completion, that signal is the wake and the
|
|
||||||
finished output is read afterwards, because a follow on a file nothing is
|
|
||||||
writing cannot tell *not yet* from *never* (#336). Prefer verification
|
|
||||||
over argument — add the test that settles the doubt.
|
|
||||||
3. Never dismiss a review, never merge, never mark your own work as passed.
|
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||||
A blocking point you disagree with is answered with evidence or escalated
|
A blocking point you disagree with is answered with evidence or escalated
|
||||||
in the PR; silence and force-forward are not options, and a panel
|
in the PR — silence and force-forward are not options. A panel deadlock
|
||||||
deadlock is one kind of human-owned decision (#50 D11).
|
is one kind of human-owned decision; use the ruling ask below
|
||||||
|
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||||
**A fix round may ride a draft**, and the draft changes nothing about who
|
|
||||||
owes what: a mid-round draft reads as a draft always read — the phase is
|
|
||||||
yours, the panel cannot see it — while the round outranks it, so you owe the
|
|
||||||
round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s
|
|
||||||
`state:building` row, #205). **Ready-for-review is the act that ends the
|
|
||||||
round, and it is the builder's alone**: the flip asserts the round was
|
|
||||||
answered whole, the one judgement its author cannot delegate, so an engine
|
|
||||||
may draft a PR but only the builder undrafts it. **Where a draft suppressed
|
|
||||||
the checks, green is proven at the flip and the request still follows it** —
|
|
||||||
marking ready runs the checks the draft held back, so the order is flip, let
|
|
||||||
the head answer, then request, step 1's precondition and not a second one.
|
|
||||||
Waiting there is compliance — again the request's wait, not the
|
|
||||||
declaration's — and `blocker:unrequested` does not fire while a head's
|
|
||||||
checks are pending or red (#236).
|
|
||||||
|
|
||||||
## The ruling ask
|
## The ruling ask
|
||||||
|
|
||||||
Set `needs-ruling` whenever a decision belongs to a human: org policy,
|
Set `needs-ruling` whenever a decision belongs to a human: org policy,
|
||||||
published artifacts, secrets, prod, or any choice whose cost lands outside
|
published artifacts, secrets, prod, or any choice whose cost lands outside
|
||||||
the PR — a panel deadlock is one instance, not the definition. The builder
|
the PR. A panel deadlock is one instance, not the definition. The builder is
|
||||||
is the PR's accountable flag-setter and consolidates the decision into one
|
the accountable flag-setter on a PR and consolidates the decision into one
|
||||||
comment rather than forwarding several reviewers' phrasings (#50 D11).
|
comment rather than forwarding several reviewers' phrasings
|
||||||
|
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||||
|
|
||||||
Keep at most these five lines above the fold, all other analysis inside it.
|
Keep at most these five lines above the fold and put all other analysis
|
||||||
The field labels are fixed because the ruling machinery checks for them (#50
|
inside the fold. The field labels are fixed because the ruling machinery
|
||||||
D12):
|
checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
🧭 needs-ruling — <the decision, one line>
|
🧭 needs-ruling — <the decision, one line>
|
||||||
|
|
@ -262,48 +204,48 @@ Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
|
||||||
The options must be exhaustive and mutually exclusive; more than three means
|
The options must be exhaustive and mutually exclusive; more than three means
|
||||||
the question is not ready. `Recommend:` is mandatory — omitting it hands the
|
the question is not ready. `Recommend:` is mandatory — omitting it hands the
|
||||||
whole problem to the human. `Blocked:` names both what stops and what
|
whole problem to the human. `Blocked:` names both what stops and what
|
||||||
continues. Write a timed `Default:` only when affirmatively confident the
|
continues. Write a timed `Default:` only when you are affirmatively confident
|
||||||
decision is reversible inside the PR before merge; unsure is not a tie but a
|
the decision is reversible inside the PR before merge. Unsure is not a tie:
|
||||||
hard block, as published artifacts, secrets, prod and org policy are by
|
it is a hard block. Published artifacts, secrets, prod, and org policy are
|
||||||
construction (#50 D12–D13).
|
hard blocks by construction ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||||
|
|
||||||
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
|
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
|
||||||
event**, not its `Default:` deadline or the last activity (#50 D13–D14):
|
event**, not its `Default:` deadline or the last activity
|
||||||
|
([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)):
|
||||||
|
|
||||||
- **0–12h:** proceed when a still-clear, reversible default expires, saying
|
- **0–12h:** proceed when a still-clear, reversible default expires, and say
|
||||||
out loud that you did; a hard block waits.
|
out loud that you did. A hard block waits.
|
||||||
- **at 12h:** do not fire a stale default — re-read it against what has
|
- **at 12h:** do not fire a stale default. Re-read it against what has landed
|
||||||
landed, and where doubt has appeared, make it a hard block.
|
and ask whether it still holds and whether reasonable doubt remains. If
|
||||||
- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the
|
doubt has appeared, make it a hard block.
|
||||||
body which way you went and what doubt remains. Nothing merges by this;
|
- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the
|
||||||
|
PR body which way you went and what doubt remains. Nothing merges by this;
|
||||||
the human still gates the merge.
|
the human still gates the merge.
|
||||||
- **past 24h:** hand the choice to triage, which picks the option, records
|
- **past 24h:** hand the choice to triage. Triage picks the option, records it
|
||||||
it as a decision, and stays accountable; the operator can overturn it at
|
as a decision, and remains accountable; the operator can overturn it at
|
||||||
merge.
|
merge.
|
||||||
|
|
||||||
A re-flag starts a fresh ladder, which applies whatever `Default:` says,
|
A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says,
|
||||||
hard block included, and an active back-and-forth still climbs it — unlike
|
including a hard block, and an active back-and-forth still climbs it. This is
|
||||||
the 7-day nudge, which resets on real activity. The machine observes both
|
different from the 7-day nudge, which resets on real activity. The machine
|
||||||
clocks but never sets, clears, or decides `needs-ruling`. The label stays
|
observes both clocks but never sets, clears, or decides `needs-ruling`.
|
||||||
until agreement is *reached*, not until the maintainer replies: the setter
|
|
||||||
records the ruling, removes the label, and returns the item to its flow in
|
The label stays until agreement is *reached*, not until the maintainer
|
||||||
the same comment ([LABELS.md](LABELS.md)).
|
replies. The setter records the ruling, removes the label, and returns the
|
||||||
|
item to its flow in the same comment ([LABELS.md](LABELS.md)).
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
|
|
||||||
When the round passes — every panel verdict approving the **current head**,
|
When the round passes — every panel verdict approves the **current head**,
|
||||||
no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this
|
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
|
||||||
is a release PR) — the engine does these steps for the builder, in order:
|
this is a release PR) — hand it to the human, in order:
|
||||||
|
|
||||||
1. request the human's review;
|
1. post the round summary (what changed per round, what was verified);
|
||||||
2. set `state:needs-human`;
|
2. request the human's review;
|
||||||
3. post the engine-rendered handoff comment: approvals at the current head,
|
3. set `state:needs-human` yourself.
|
||||||
the head SHA, and a pointer to the PR body's **Round log**.
|
|
||||||
|
|
||||||
The builder composes no new summary: the authored record already lives in
|
The label write is optimistic — the reconciler validates it, and takes it
|
||||||
the Round log, mirrored from each whole-round reply. The label write is
|
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
|
||||||
optimistic — the reconciler validates it and takes it back if the PR is not
|
human's. The claim is now parked as shape 4 (Picking, above) — the handoff
|
||||||
mergeable-right-now. Then stop: the PR is the human's, and the claim parks
|
you just posted is its declaration, and your build slot is free. Address
|
||||||
as shape 4 (Picking, above), that comment its declaration and your slot
|
what comes back (`state:addressing`) and re-hand-off the same way.
|
||||||
free. Address what comes back (`state:addressing`) and re-hand-off the same
|
|
||||||
way.
|
|
||||||
|
|
|
||||||
913
CHANGELOG.md
913
CHANGELOG.md
|
|
@ -2,915 +2,10 @@
|
||||||
|
|
||||||
The curated history of the ceremony itself. Each release's section is
|
The curated history of the ceremony itself. Each release's section is
|
||||||
published verbatim as that release's body (lib/changelog.sh extracts it),
|
published verbatim as that release's body (lib/changelog.sh extracts it),
|
||||||
so entries say what changed, cite the issue, and stop — at most 300
|
so entries say what changed, cite the issue, and stop. Entries arrive as
|
||||||
characters each, guard-enforced on the PR that writes the fragment (#167);
|
fragments — one `changelog.d/<issue>.md` per PR, never an edit to this
|
||||||
a genuinely long change ships several short entries, never one long one.
|
file — and the release PR assembles them into the next section here
|
||||||
The citation is guard-enforced too, and it closes the entry: one `(#N)`
|
(`bin/changelog-assemble`, #112).
|
||||||
group, then the final `.` and nothing after it (#262). Sections published
|
|
||||||
before that rule keep their prose; the guard reads fragments only.
|
|
||||||
Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
|
|
||||||
an edit to this file — and the release PR assembles them into the next
|
|
||||||
section here (`bin/changelog-assemble`, #112).
|
|
||||||
|
|
||||||
This tree is `heavy-duty/ceremony` on
|
|
||||||
`forgejo.heavyduty.builders`, and it tracks the upstream tree's version
|
|
||||||
numbers (#197 D2). Two trees therefore answer to the same number, differing
|
|
||||||
by the forge-compatibility delta. **This tree carries upstream through
|
|
||||||
`8c3a4d1`** (upstream `0.6.0`, merged by #198); the `0.4.1` section below is
|
|
||||||
this forge's own release, not upstream's, and upstream `0.6.1` through `0.6.3`
|
|
||||||
were adopted by port rather than merge. Upstream's separate `0.4.1` section
|
|
||||||
is deliberately not carried — the tag published here is the one this section
|
|
||||||
is the body of. Each sync updates this line (docs/UPSTREAM-SYNC.md, #200).
|
|
||||||
|
|
||||||
## 0.6.3 — 2026-08-26
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- The shipped 0.6.2 changelog section now carries #238's entry, which its release PR's merge base could not see; the published 0.6.2 release body is left as tagged, so tree and publication differ by that one line (#238, #231).
|
|
||||||
- Replace the unavailable intake rule with a proposal form that triage converts into work or refuses with reasons (#247).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The `needs-triage` label now directs untriaged issues toward work normalization or a reasoned refusal instead of an unavailable discussion path (#265).
|
|
||||||
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).
|
|
||||||
- Kept drill doctrine's release-path instructions aligned with the executable manifest by removing its duplicate path list (#251).
|
|
||||||
- Preserve Forgejo workflow names in status rollups so the label reconciler excludes only its own checks. (#243).
|
|
||||||
- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241).
|
|
||||||
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).
|
|
||||||
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).
|
|
||||||
|
|
||||||
## 0.6.2 — 2026-08-24
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `upstream-0.6.1` was ported in #229: CONTRIBUTING routes the vendored set
|
|
||||||
through `docs/VENDORED.txt` — upstream#316 and upstream#311; BUILDER.md
|
|
||||||
scopes the green-check precondition to its act, upstream#330; RELEASES.md
|
|
||||||
adds the post-merge gate-member split, upstream#329 (#246).
|
|
||||||
|
|
||||||
- `upstream-0.6.2` was ported in #229: BUILDER.md orders parked claims so an
|
|
||||||
operator-owned remainder parks the claim, never the handoff — upstream#336
|
|
||||||
(#246).
|
|
||||||
|
|
||||||
- `upstream-0.6.3` was ported in #230: release windows read `## Members` with
|
|
||||||
no gate fallback, carriers leave their own gates, stale board flags stay
|
|
||||||
silent — upstream#343 and upstream#327 — and the parser accepts CommonMark
|
|
||||||
rows (#246).
|
|
||||||
|
|
||||||
- Upstream logic was ported onto this forge's Forgejo-adapted issue-flow
|
|
||||||
reconciler, test, and CONTRIBUTING; those files were never overwritten with
|
|
||||||
upstream bytes (#246).
|
|
||||||
|
|
||||||
- Upstream's drill-record fixes and the upstream `0.7.0`–`0.7.4` line are
|
|
||||||
deferred to the next sync campaign (#246).
|
|
||||||
|
|
||||||
- No upstream ancestry moves in this release: `.upstream-ref` remains
|
|
||||||
`8c3a4d1` from upstream `0.6.0`, merged by #198; `upstream-0.6.3` is the
|
|
||||||
content baseline, not a merge-base (#246).
|
|
||||||
- Release windows now read membership from a dedicated `## Members` record, with CommonMark-bounded rows and no fallback to predecessor gates (#230).
|
|
||||||
- Forge consumers now receive the upstream 0.6.1 and 0.6.2 doctrine for vendored-set routing, review-round signalling, operator-owned remainders, producer liveness, and post-merge release edges (#229).
|
|
||||||
- `docs/RUNNER-PROBES.md` records the delivered 0.6.1 consumer exercise in the standing Forgejo runner venue (#217).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Review-round state now reads each forge's live review-request set directly, so stale Forgejo approvals no longer hand an in-progress fix round back to the panel (#238).
|
|
||||||
- Forgejo drafts and fast-forward conflict-check windows no longer surface as merge conflicts when the API has not distinguished one (#236).
|
|
||||||
- Forgejo review requests no longer count as verdicts, while its blocking and comment states now grade like their GitHub equivalents (#235).
|
|
||||||
|
|
||||||
## 0.6.1 — 2026-08-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `test/labels-bootstrap.test.sh` pins the bridge at every hop: the declared
|
|
||||||
boundary, both gate sites as the identity, no expression reading
|
|
||||||
`github.event.inputs`, and the caller and stub pass-throughs
|
|
||||||
byte-exact (#215).
|
|
||||||
|
|
||||||
- The same test drives the four value paths — schedule-empty, `no`, `yes`,
|
|
||||||
invalid — through the shipped expressions into the action's real
|
|
||||||
validator (#215).
|
|
||||||
|
|
||||||
- The taxonomy bootstrap keys on the `BOOTSTRAP` input, never the event name.
|
|
||||||
It tested `GITHUB_EVENT_NAME = workflow_dispatch` — correct while an
|
|
||||||
operator's manual dispatch was the only dispatch there was, inert-by-
|
|
||||||
construction from #209 on, when every machine wake became a dispatch
|
|
||||||
event (#215).
|
|
||||||
|
|
||||||
- The venue drill caught that: with the bridge delivering `no` perfectly,
|
|
||||||
drill runs 16/17 still bootstrapped, because the script never read the
|
|
||||||
input the whole chain existed to deliver (#215).
|
|
||||||
|
|
||||||
- `test/labels-reconcile.test.sh` pins the regression pair exactly: a
|
|
||||||
`workflow_dispatch` event with `BOOTSTRAP=no` (or unset) creates and
|
|
||||||
deletes nothing; only `BOOTSTRAP=yes` bootstraps (#215).
|
|
||||||
- A gather-level case drives the real board read against a Forgejo-shaped
|
|
||||||
fixture — every entry carrying the key. The existing discriminator cases
|
|
||||||
assert `jq` expressions in isolation and passed throughout this regression
|
|
||||||
(#210).
|
|
||||||
|
|
||||||
- A source pin forbids `has("pull_request")` on this surface, because the rule
|
|
||||||
was stated in a comment and violated forty lines below it. It strips comments,
|
|
||||||
so the #188 warning that explains the trap is allowed to stay (#210).
|
|
||||||
|
|
||||||
- All three sites are covered behaviourally, not only by the pin: the board
|
|
||||||
gather, the release-body gather through an observable window flag, and the
|
|
||||||
per-issue payload check (#210).
|
|
||||||
- `test/forge-backends.test.sh` pins each backend's path **and** field, because
|
|
||||||
a stubbed `forge_api` cannot catch a wrong path — which is how this shipped
|
|
||||||
and why a live sweep was what found it (#209).
|
|
||||||
- `test/labels-dispatch.test.sh` extracts the shipped step and executes it
|
|
||||||
against a recording stub, asserting the method, endpoint, ref and
|
|
||||||
`inputs.bootstrap` actually sent (#205).
|
|
||||||
|
|
||||||
- That test also drives the failure path: any non-204 still fails the job, so
|
|
||||||
the misconfiguration alarm the trigger exists to be cannot decay into a
|
|
||||||
warning (#205).
|
|
||||||
|
|
||||||
- An unset `GITHUB_API_URL` refuses before any request instead of defaulting
|
|
||||||
to `api.github.com`. Guessing sent this forge's dispatch to GitHub and
|
|
||||||
reported success — the "Never 'probably github'" rule, and the same
|
|
||||||
unset-environment refusal #201 established for docs-sync (#205).
|
|
||||||
|
|
||||||
- A dispatch that never reaches the forge names the failure. Letting `set -e`
|
|
||||||
carry curl's exit code out did fail the job, but with a bare status and no
|
|
||||||
sentence (#205).
|
|
||||||
|
|
||||||
- `docs/CONSUMERS.md` and both caller comments describe the REST dispatch, and
|
|
||||||
the manual bootstrap command carries a forge-neutral form beside the `gh`
|
|
||||||
one — a cross-forge runbook that directs this forge to a missing binary is
|
|
||||||
wrong even where the surrounding prose is right (#205).
|
|
||||||
- `docs/RUNNER-PROBES.md` documents the standing runner-probe venue,
|
|
||||||
`heavy-duty/ceremony-runner-probe` — the place runner-only facts are measured
|
|
||||||
on demand, ruled as option A by the operator (#202).
|
|
||||||
|
|
||||||
- `drills/README.md` cross-links it beside the disposal rule, so the exception
|
|
||||||
is visible where the dangerous habit lives (#202).
|
|
||||||
|
|
||||||
- The runbook states that the drill disposal rule does **not** apply to it.
|
|
||||||
Archiving it defeats its purpose, and that is exactly how the three existing
|
|
||||||
drill repos each became unavailable (#202).
|
|
||||||
|
|
||||||
- It records that a probe must run as an Actions job under the workflow token:
|
|
||||||
the same call answers 500 there and 204 under a PAT, so a probe run any other
|
|
||||||
way produces a confident wrong answer (#202).
|
|
||||||
|
|
||||||
- Creating the repo is recorded as the operator's step, measured rather than
|
|
||||||
assumed: a fleet identity gets 403 on org repo creation and 201 in its own
|
|
||||||
namespace (#202).
|
|
||||||
|
|
||||||
- It carries an executable two-layer arming procedure: an immutable candidate
|
|
||||||
code SHA and an armed workflow commit on top of it. A single layer is
|
|
||||||
self-referential — rewriting a workflow makes a new commit, and a commit
|
|
||||||
cannot contain its own object ID (#202).
|
|
||||||
|
|
||||||
- Callers are pinned by layer: composite actions to the candidate code SHA,
|
|
||||||
reusable workflows to the armed SHA, which is the only revision whose inner
|
|
||||||
checkout points at the fork (#202).
|
|
||||||
|
|
||||||
- The arming gate asserts what each carrier IS, not only that the old literal
|
|
||||||
is gone: every `repository:` equals the fork, every `CEREMONY_SELF_REF` value
|
|
||||||
equal the candidate code SHA, and callers match the layer they belong to
|
|
||||||
(#202).
|
|
||||||
|
|
||||||
- It enumerates the carriers from the tree rather than encoding a count, and
|
|
||||||
distinguishes ceremony's internal self-checkouts from the consumer checkouts
|
|
||||||
that must stay `${{ github.repository }}` (#202).
|
|
||||||
|
|
||||||
- Both published snippets are ShellCheck-clean when extracted and linted
|
|
||||||
directly, not merely as part of the repository sweep (#202).
|
|
||||||
|
|
||||||
- The checker validates the MANIFEST against the target it was given, so a
|
|
||||||
manifest that describes a wrong arming consistently — wrong fork, or the
|
|
||||||
armed SHA where the candidate belongs — refuses instead of matching a tree
|
|
||||||
rewritten to the same wrong value (#202).
|
|
||||||
|
|
||||||
- The manifest is generated from the PRE-arming tree, which is the only order
|
|
||||||
that enumerates the carriers that must change (#202).
|
|
||||||
|
|
||||||
- Both published snippets were driven against a constructed candidate/probe
|
|
||||||
pair: deletion, both role swaps, wrong owner, wrong
|
|
||||||
SHA, wrong path, a deleted caller class and an extra carrier all refuse, and
|
|
||||||
the armed control passes (#202).
|
|
||||||
|
|
||||||
- The manifest records complete caller coordinates, so a path swapped under the
|
|
||||||
right owner and SHA is caught (#202).
|
|
||||||
|
|
||||||
- Generator and checker share one domain — ceremony callers — so a third-party
|
|
||||||
`actions/checkout` is neither manifested nor reported as unrecognised (#202).
|
|
||||||
|
|
||||||
- Probe results are written to an issue in the probe repo and carried to the
|
|
||||||
ceremony issue by a human, so the probe holds no path that can write to the
|
|
||||||
live board (#202).
|
|
||||||
- `test/docs-sync.test.sh` drives the fetch path, which had no coverage at all:
|
|
||||||
every existing row passes `--source`, which overrides the fetch entirely
|
|
||||||
(#201).
|
|
||||||
|
|
||||||
- A stubbed `curl` records the requested URL and serves a tarball, so which
|
|
||||||
forge a pin resolves against is a tested decision rather than plumbing (#201).
|
|
||||||
- `docs/UPSTREAM-SYNC.md` — the recurring upstream sync as a runbook: the
|
|
||||||
standing resolutions, which side wins each and the issue that decided it
|
|
||||||
(#200).
|
|
||||||
|
|
||||||
- It names the step the 0.6.0 sync nearly shipped without: auditing what the
|
|
||||||
merge brought in that did **not** conflict. `git merge` asks no question
|
|
||||||
about a function upstream added to a file this tree owns (#200).
|
|
||||||
|
|
||||||
- It records that the same mechanic applies to state, not just to call sites: a
|
|
||||||
resolved region can silently remove a producer whose consumers auto-merged,
|
|
||||||
and every one of those consumers degrades to empty rather than erroring
|
|
||||||
(#200).
|
|
||||||
|
|
||||||
- It says to verify with the runner's tooling, because "green locally" was
|
|
||||||
wrong three times in one sync — untracked files, a pinned linter, and a
|
|
||||||
pinned `jq` whose empty-input exit code differs (#200).
|
|
||||||
|
|
||||||
- It says every branch open across a sync is stale afterwards — Forgejo never
|
|
||||||
re-tests an open PR when main moves, so a prior approval is evidence about a
|
|
||||||
tree that no longer exists (#200).
|
|
||||||
|
|
||||||
- It says to audit post-merge runs by executed steps rather than colour, and to
|
|
||||||
inventory what the sync changed about workflow triggers and jobs first (#200).
|
|
||||||
|
|
||||||
- `.upstream-ref` records the upstream commit this tree carries, in
|
|
||||||
machine-readable form beside the CHANGELOG's prose (#200).
|
|
||||||
|
|
||||||
- `test/upstream-delta.test.sh` fails the PR that scatters a forge decision
|
|
||||||
into a file the inventory does not name. Discovery is derived from the tree,
|
|
||||||
so a composite `action.yml` or a `.yaml` workflow is seen without anyone
|
|
||||||
remembering to add a glob (#200).
|
|
||||||
|
|
||||||
- Discovery is git's, not the filesystem's: `ls-files`, so the tarballs `ci.yml`
|
|
||||||
extracts into the checkout and any developer cache are not parsed as source
|
|
||||||
(#200).
|
|
||||||
|
|
||||||
- It refuses when the recorded commit is missing, absent from the object store,
|
|
||||||
or not an ancestor — three distinct refusals, none of them a skip. `ci.yml`
|
|
||||||
fetches that exact object so the test reads local evidence without CI
|
|
||||||
omitting it (#200).
|
|
||||||
|
|
||||||
- Its mutation cases drive the real check against a constructed tree, so
|
|
||||||
replacing the guard with `return 0` reds five of them (#200).
|
|
||||||
|
|
||||||
- `docs/CONSUMERS.md` states that two ceremonies answer to the same version
|
|
||||||
number, and how a consumer says which one it pinned (#200).
|
|
||||||
- `lib/issue_references.sh` — the LOCAL/CROSS classifier, moved out of
|
|
||||||
`actions/issueflow-reconcile`'s executable so a second caller can use it
|
|
||||||
without sourcing a reconciler, which would run one (#199, #61).
|
|
||||||
|
|
||||||
- `test/refs-not-closing.test.sh` drives the action's boundary on **both**
|
|
||||||
backends with stubs at the transport, proving one fixture yields the same
|
|
||||||
verdict on each — including a closing keyword that appears only in a commit
|
|
||||||
message (#199).
|
|
||||||
- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`):
|
|
||||||
`lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded
|
|
||||||
reads, and the ruling and window rules (#198).
|
|
||||||
|
|
||||||
- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh`
|
|
||||||
outside `lib/forge-github.sh` unless the file declares
|
|
||||||
`CEREMONY_FORGE_CLIENT=gh` (#198).
|
|
||||||
|
|
||||||
- `CHANGELOG.md` names the upstream commit this tree carries, so a drill
|
|
||||||
record can say which `0.6.0` it exercised (#197, #198).
|
|
||||||
- `test/labels.test.sh` holds the conf's roster and `CONTRIBUTING.md`'s roster
|
|
||||||
table to the same set, in both directions, so a roster edit that touches one
|
|
||||||
file and not the other goes red instead of drifting quietly (#195).
|
|
||||||
- `test/forge-backends.test.sh` pins the replacement contract: preserve
|
|
||||||
unrelated labels across a combined add+remove, an absent removal as a
|
|
||||||
successful no-op, the empty set as a full clear, and `forge_labels_add`
|
|
||||||
still `POST`-only, per ceremony#128 (#192).
|
|
||||||
|
|
||||||
- `test/labels-reconcile.test.sh` drives a failing write through `main()` — the
|
|
||||||
swallow was in the loop, where a fixture-level probe cannot reach (#192).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- The review panel restores `kimi-reviewer-andresmgsl` alongside GLM. The
|
|
||||||
four-identity panel now requires three cross-vendor approvals after the PR
|
|
||||||
author recuses (#224).
|
|
||||||
- The review panel names `glm-reviewer-andresmgsl` in place of
|
|
||||||
`kimi-reviewer-andresmgsl` while that identity is unavailable. The panel
|
|
||||||
stays three, so a PR still requires two cross-vendor approvals (#222).
|
|
||||||
|
|
||||||
- `test/labels.test.sh`'s table-side roster mutation names an identity the
|
|
||||||
table actually carries. Naming a departed one would mutate nothing and the
|
|
||||||
case would pass while proving nothing — #195's rot class one layer down
|
|
||||||
(#222).
|
|
||||||
- This forge's release line runs `0.4.1 → 0.6.1`: versions 0.5.0 and 0.6.0
|
|
||||||
arrived here by merge from the read-only upstream and were never released
|
|
||||||
on this forge (#220).
|
|
||||||
|
|
||||||
- The `## 0.6.0` section this changelog carries is upstream's — its entries
|
|
||||||
describe upstream's work under upstream's issue numbers. The forge port's
|
|
||||||
own work ships first in 0.6.1 (#220).
|
|
||||||
- `docs/RUNNER-PROBES.md` records the venue's first delivered drills — the
|
|
||||||
#192 asymmetry re-observed on demand under the workflow token, the dispatch
|
|
||||||
route's 204 under both identities, and #215's boundary finding — each with
|
|
||||||
the probe-issue URL it is recorded in (#202).
|
|
||||||
|
|
||||||
- Two venue lessons join the runbook where the next probe author will look:
|
|
||||||
findings must be written to issues because the venue's log route 404s for
|
|
||||||
non-admin reads, and report content sent to the forge must never contain a
|
|
||||||
credential expression or value (#202).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The sweep's `bootstrap` value crosses the `workflow_call` boundary as a
|
|
||||||
declared input, explicitly passed by the caller — the one channel measured
|
|
||||||
to work; the called workflow did not see the caller's event inputs as an
|
|
||||||
implicit substitute on this instance (#215).
|
|
||||||
|
|
||||||
- Before the bridge, `github.event.inputs` was empty inside the called
|
|
||||||
workflow, so every dispatch-woken sweep bootstrapped: ~20 label upserts on
|
|
||||||
each board event (#215).
|
|
||||||
|
|
||||||
- The caller maps an empty top-level value to `no` explicitly, so a
|
|
||||||
cron-woken sweep can never bootstrap; the declared input also defaults to
|
|
||||||
`no`, so a consumer that passes nothing gets the safe path (#215).
|
|
||||||
|
|
||||||
- The gate feeds the declared input to `labels-reconcile` unchanged, so an
|
|
||||||
invalid value meets the action's own `yes|no` refusal instead of being
|
|
||||||
silently coerced (#215).
|
|
||||||
|
|
||||||
- `docs/CONSUMERS.md`'s published sweep stub carries the same pass-through —
|
|
||||||
without it every consumer inherits the defect ceremony fixed for
|
|
||||||
itself (#215).
|
|
||||||
- `issueflow-reconcile` sees this forge's issues again. The board gather used
|
|
||||||
`has("pull_request")`, and every Forgejo entry carries that key — so it
|
|
||||||
selected zero rows on every sweep while printing `reconciled.` (#210).
|
|
||||||
|
|
||||||
- Three sites take `.pull_request == null`, the discriminator the file's own
|
|
||||||
comment already specified and that one of its four call sites already used
|
|
||||||
(#210).
|
|
||||||
|
|
||||||
- `post-merge` transitions can fire again: they could not, because the sweep
|
|
||||||
saw no issues to transition (#210).
|
|
||||||
- `blocker:unrequested` is judged on this forge again. The head-commit date was
|
|
||||||
read from `repos/{o}/{r}/commits/{sha}`, which Forgejo answers **404** — so
|
|
||||||
every sweep degraded and left the blocker unjudged (#209).
|
|
||||||
|
|
||||||
- `forge_commit_at` is a verb on both backends: GitHub serves a single commit at
|
|
||||||
the bare path with the date nested, Forgejo at `git/commits/{sha}` with it
|
|
||||||
under `.created`. The caller asks for one timestamp and knows neither shape
|
|
||||||
(#209).
|
|
||||||
- `.github/workflows/labels.yml` wakes the sweep over REST instead of
|
|
||||||
`gh workflow run`, so a board event reconciles within seconds on any forge
|
|
||||||
rather than waiting up to an hour for the scheduled sweep (#205).
|
|
||||||
|
|
||||||
- The workflow-dispatch endpoint has the same shape on both forges, so that
|
|
||||||
step no longer decides one: the `CEREMONY_FORGE_CLIENT=gh` declaration and
|
|
||||||
both inline refusals are gone rather than ported (#205).
|
|
||||||
|
|
||||||
- The dispatch supplies its `ref` explicitly, because REST has no default
|
|
||||||
branch where `gh workflow run` had one, and refuses without it (#205).
|
|
||||||
|
|
||||||
- It takes that ref from the repository, never from `GITHUB_REF_NAME` — on a
|
|
||||||
`pull_request_target` run that is `<n>/merge`, which is not a branch (#205).
|
|
||||||
|
|
||||||
- A failed dispatch names the endpoint, the ref and the status, and says that
|
|
||||||
an empty `500` body from Forgejo means the workflow name or the ref did not
|
|
||||||
resolve — a bare status sends the reader after a server fault that is not
|
|
||||||
there (#205).
|
|
||||||
- `actions/docs-sync` fetches the doctrine mirror from the forge named by
|
|
||||||
`GITHUB_SERVER_URL` instead of a hard-coded `github.com` (#201).
|
|
||||||
|
|
||||||
- The same pin ref names a different tree on each forge, so a consumer's mirror
|
|
||||||
was verified against a tree it never pinned — and with HTTP 200, so `--check`
|
|
||||||
reported drift that could not be fixed (#201).
|
|
||||||
|
|
||||||
- A fetch that cannot name its forge now refuses instead of guessing: no
|
|
||||||
`GITHUB_SERVER_URL` and no `--source` exits naming the variable, having
|
|
||||||
reached for no network (#201).
|
|
||||||
|
|
||||||
- A failed fetch names the URL it actually tried, and asks whether the ref
|
|
||||||
exists on that forge rather than in the abstract (#201).
|
|
||||||
- `actions/refs-not-closing` gathers over REST through the forge shim instead
|
|
||||||
of one GraphQL query, so it produces a real verdict on Forgejo — which
|
|
||||||
serves no GraphQL surface at all — rather than refusing (#199).
|
|
||||||
|
|
||||||
- The closing set is parsed by `lib/closes_references.sh` over the PR body
|
|
||||||
**and** every commit message, unioned. Forgejo honours closing keywords in
|
|
||||||
commit messages, so a body-only port would miss a PR that closes an issue
|
|
||||||
from a commit subject (#199).
|
|
||||||
|
|
||||||
- The `hasNextPage` refusal is carried onto the paginated commit read: an
|
|
||||||
incomplete gather refuses instead of returning a partial verdict, reusing
|
|
||||||
the backend's `x-total-count` completeness proof (#199).
|
|
||||||
|
|
||||||
- A failed read never reaches the parser. An unread body parses to an empty
|
|
||||||
closing set, which is a passing verdict the action never earned (#199).
|
|
||||||
|
|
||||||
- `.github/workflows/refs-guard.yml` no longer gates the job on the forge.
|
|
||||||
A portable action behind a github-only gate is a guard that passes by never
|
|
||||||
running (#199, #198).
|
|
||||||
|
|
||||||
- The `CEREMONY_FORGE_CLIENT=gh` declaration is gone, and the contract test
|
|
||||||
asserts its absence: an opt-out with no `gh` behind it is a standing
|
|
||||||
permission slip (#199).
|
|
||||||
- Eight runtime `gh` call sites arrived with the merge outside every conflict
|
|
||||||
hunk, in functions upstream added to files this tree already owned. Seven
|
|
||||||
are ported onto the shim; the eighth is named with its reason (#198).
|
|
||||||
|
|
||||||
- The open-PR gather reads `Refs`, not only closing keywords. Reading one side
|
|
||||||
for closing links and the other for `Refs` is what released a live claim in
|
|
||||||
crew#321, and this tree carried that shape (#198).
|
|
||||||
|
|
||||||
- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers
|
|
||||||
the PR that merged last rather than the highest-numbered one. Without the
|
|
||||||
column every sort key ties and the old order returns silently (#198).
|
|
||||||
|
|
||||||
- The open gather feeds `open_pr_issues` one record per physical body line. A
|
|
||||||
whole decoded body as one record loses every declaration including the
|
|
||||||
first, and reclaims a claim a live PR was holding (#198).
|
|
||||||
|
|
||||||
- The post-merge nudge links the issue on the forge in play rather than a
|
|
||||||
hard-coded `github.com` (#198).
|
|
||||||
|
|
||||||
- `actions/refs-not-closing` reports and skips on a forge it cannot speak,
|
|
||||||
naming the client and #199, instead of standing red on every PR. It reaches
|
|
||||||
the forge zero times, so no verdict is produced either way (#198).
|
|
||||||
|
|
||||||
- `.github/workflows/labels.yml`'s sweep dispatch declares the client it
|
|
||||||
speaks and decides the FORGE before the binary, so a Forgejo runner that
|
|
||||||
happens to ship `gh` cannot dispatch against a forge that cannot serve it.
|
|
||||||
#205 ports it to REST (#198).
|
|
||||||
|
|
||||||
- `actions/refs-not-closing` fails closed on a forge it cannot speak, and
|
|
||||||
`.github/workflows/refs-guard.yml` carries the scheduling decision — the
|
|
||||||
action never reports a success it did not earn (#198).
|
|
||||||
|
|
||||||
- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7.
|
|
||||||
`jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this
|
|
||||||
instance's runner carries 1.6 — so the guard #247 D3 added to refuse an
|
|
||||||
unreadable read was accepting one here (#198).
|
|
||||||
|
|
||||||
- The post-merge nudge strips a trailing slash from the server URL, so a forge
|
|
||||||
URL carrying one does not render `//owner/repo` (#198).
|
|
||||||
|
|
||||||
- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on
|
|
||||||
the release doors' executable path here, so a doors-unchanged record that
|
|
||||||
omitted it was measuring the wrong set (#198).
|
|
||||||
- `.github/labels.conf` names identities that exist on the forge this repo
|
|
||||||
lives on. All five it named before were absent, so `panel=` could never
|
|
||||||
converge a review round and `triage-actors=` made every issue a stray mint
|
|
||||||
nobody could normalize (#195).
|
|
||||||
|
|
||||||
- `CONTRIBUTING.md`'s roster table matches the conf: three identities, the
|
|
||||||
human row is `andres`, and the approval count states what panel-minus-author
|
|
||||||
actually resolves to on this roster rather than a stale three (#195).
|
|
||||||
- Label removal on Forgejo is a full-set `PUT`, not a per-label `DELETE`. The
|
|
||||||
workflow token gets HTTP 500 on every `DELETE .../labels/{id}` on this
|
|
||||||
instance, so the state machine could only ever ADD labels (#192).
|
|
||||||
|
|
||||||
- Every `state:*` transition that needs the previous state cleared, and every
|
|
||||||
`blocker:*` that should lift, can now actually clear. They were inert (#192).
|
|
||||||
|
|
||||||
- A label edit that fails is fatal to `labels-reconcile`, matching
|
|
||||||
`issueflow-reconcile`. One cause had two contradictory policies (#192).
|
|
||||||
|
|
||||||
- A failed write reaches the sweep's exit code: per-PR tolerance is kept for
|
|
||||||
READS, but a sweep that could not write exits non-zero and its output carries
|
|
||||||
no `reconciled.` token at all (#192).
|
|
||||||
|
|
||||||
- Every label mutation goes through one checked helper, so clearing
|
|
||||||
`merge-next` or either `stale` edit fails the sweep too — not only the
|
|
||||||
primary state edit (#192).
|
|
||||||
|
|
||||||
- A preserved label keeps the id the issue payload already carried, so
|
|
||||||
preservation does not depend on a repository-wide list that has nothing to do
|
|
||||||
with the issue (#192).
|
|
||||||
|
|
||||||
- A removal that changes nothing writes nothing, rather than replacing the set
|
|
||||||
with itself and opening a race for no state change (#192).
|
|
||||||
|
|
||||||
- Every failure diagnostic on the forgejo backend names the verb as well as the
|
|
||||||
path and the status. A read used to say `HTTP 500 from 'repos/…'`, which
|
|
||||||
cannot be told from a failed write of the same path (#192).
|
|
||||||
|
|
||||||
- The diagnostic names what was attempted and that it did not happen, instead
|
|
||||||
of blaming a missing label and telling the operator to bootstrap — a cause it
|
|
||||||
had not established (#192, #101).
|
|
||||||
|
|
||||||
- An add-label the repo does not carry refuses before any write, so a
|
|
||||||
replacement `PUT` can never drop a label nobody asked to remove (#192).
|
|
||||||
|
|
||||||
## 0.6.0 — 2026-08-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an
|
|
||||||
unassigned claim under `needs-ruling` must draw its board diagnostic and
|
|
||||||
its ruling nudge in one sweep, so a read that drifts below the diagnostic
|
|
||||||
reds instead of silently costing the escalation 7 days (#284, #307).
|
|
||||||
- The issue-flow sweep now flags a collision the board never declared: two
|
|
||||||
open, unblocked issues whose titles name one deliverable draw a comment
|
|
||||||
naming the newer's owed `Blocked by` edge. Keys normalize, so
|
|
||||||
`actions/x` and `x` are one deliverable (#288).
|
|
||||||
- The sweep now flags an unblocked non-member during a standing release
|
|
||||||
window, naming the window's invariant. `claimed` counts, PR in flight or
|
|
||||||
not. The gate is read from the release issue's own `Blocked by`
|
|
||||||
declarations, and an emptied gate leaves it dormant (#292).
|
|
||||||
- Both flags are advisory: comments only, no label write and no state
|
|
||||||
change, deduped against each family's last word on the thread so a
|
|
||||||
standing state re-sweeps silently (#293).
|
|
||||||
- The fragment guard now requires each entry to end with its issue
|
|
||||||
citation: one `(#N)` group — local, `repo#N` or `owner/repo#N`
|
|
||||||
references separated by `, ` — then the final `.` and nothing after it
|
|
||||||
(#262).
|
|
||||||
- The refusal distinguishes an entry carrying no reference at all from one
|
|
||||||
whose reference is present but not terminal, and names the shape to
|
|
||||||
write in both (#262).
|
|
||||||
- The 300-character bound still outranks the citation across the whole
|
|
||||||
fragment, and the outranked problem stays out of the message it lost
|
|
||||||
to: one fragment, one diagnosis, wherever in the file it sits (#262).
|
|
||||||
- BUILDER.md now describes a fix round that rides a draft: the draft phase
|
|
||||||
stays the builder's, ready-for-review is the builder's own act, and where a
|
|
||||||
draft suppressed the checks green is proven at the flip (#258).
|
|
||||||
- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in
|
|
||||||
progress rather than abandonment (#258).
|
|
||||||
- A `post-merge` item with no comment for 7 days now draws one nudge from the
|
|
||||||
issue sweep: the wake evidence is owed. A starving criterion used to be
|
|
||||||
found only when someone happened to run the right read (#254).
|
|
||||||
- Label churn does not reset that clock, and neither does an assignment: on
|
|
||||||
`post-merge` an assignee is an invalid composition, not activity, and it
|
|
||||||
must not buy the item another 7 days of silence (#254).
|
|
||||||
- The nudge names the triage actor from `triage-actors=`, not the human
|
|
||||||
reviewer: `post-merge` is triage's completion queue, so the starved wake
|
|
||||||
condition is triage's to answer (#254).
|
|
||||||
- It links the item and parses nothing from the body — which criterion
|
|
||||||
starved is prose, and the machine never judges prose (#254).
|
|
||||||
- Like the ruling nudge it carries no idempotency marker on purpose: the
|
|
||||||
comment is itself activity, so the rule self-rate-limits to one nudge per 7
|
|
||||||
quiet days. Comment-only — no path here writes a label (#254).
|
|
||||||
- Release epics now announce release initialization when their declared dependency gates clear (#253).
|
|
||||||
- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment
|
|
||||||
whenever that set changes, so a readable-but-wrong declaration is visible in
|
|
||||||
one sweep instead of days later, when a human happens to run the parser by
|
|
||||||
hand (#252).
|
|
||||||
- The echo's marker carries the parsed set itself: an unchanged parse never
|
|
||||||
re-posts on a 15-minute cron, and a changed one always speaks. Comment-only
|
|
||||||
— no path here writes a label (#252).
|
|
||||||
- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the
|
|
||||||
guard's short exemption list, so a new doctrine file can no longer reach a
|
|
||||||
tag undeclared and stay invisible to every consumer's `docs-sync` (#251).
|
|
||||||
- The same guard reads the manifest the other way: every entry must resolve to
|
|
||||||
a regular, non-empty, tracked file — no symlink, no directory, no `../`
|
|
||||||
escape (#251).
|
|
||||||
- Document the optional, operator-ruled release-epic flow for governed repositories. (#248).
|
|
||||||
- Guard documentation availability markers against missing issue citations
|
|
||||||
and release candidates that already ship the cited work (#238).
|
|
||||||
- The label and issue-flow sweeps now comment once per episode when
|
|
||||||
`attention` targets a pull request or an unassigned issue, without
|
|
||||||
retargeting the demand or changing labels or assignees (#232).
|
|
||||||
- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware
|
|
||||||
guard if GitHub would close N through a keyword or sidebar link (#218).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `README.md` is rewritten whole from the current tree: the front page names
|
|
||||||
the governance repo ceremony now is, routes to `docs/CONSUMERS.md`,
|
|
||||||
`AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and
|
|
||||||
keeps the operator's release runbook as its core, re-measured (#311).
|
|
||||||
- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292).
|
|
||||||
- TRIAGE.md now requires unconditional collision-edge chains when open issues
|
|
||||||
carry the same deliverable, keeping the ready queue concurrently claimable
|
|
||||||
(#288).
|
|
||||||
- TRIAGE.md now states its rules with bare record cites: the label-race and
|
|
||||||
lifted-hold incident narratives leave the normative text while their
|
|
||||||
operational rules remain complete (#282).
|
|
||||||
- `BUILDER.md` states its rules and cites their record bare: the incident
|
|
||||||
narratives, the links into issue comments and the cross-repo issue cites
|
|
||||||
leave the normative text, which no rule leaves with them (#281).
|
|
||||||
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule,
|
|
||||||
retain at most one sentence of why, cite the local record bare, and leave the
|
|
||||||
incident narrative in that record (#280).
|
|
||||||
- BUILDER.md's green ruled term now says which entry to read before it says
|
|
||||||
what an entry means: a check's word at a head is its newest entry by start
|
|
||||||
time, and a cancelled entry is not that word while the same check carries a
|
|
||||||
non-cancelled one at that head (#276).
|
|
||||||
- A check whose every entry at the head is cancelled is unchanged — nothing
|
|
||||||
survived to be its word, so it never reported and is not green — and the
|
|
||||||
collapse mirrors `checks_state`'s carve-out rather than adding a class
|
|
||||||
(#276).
|
|
||||||
- BUILDER.md's step 1 now rules the checkless head: no checks configured is
|
|
||||||
nothing to wait for, and the request goes out straight away — stated once,
|
|
||||||
in the ruled-term paragraph, with the draft-round restatement removed
|
|
||||||
(#272).
|
|
||||||
- `README.md` and `RELEASES.md` derive `scope:docs`, and the
|
|
||||||
`changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests
|
|
||||||
derive `scope:guards`; all five were mapped nowhere. The docs block matched
|
|
||||||
a literal `README`, which this tree does not carry (#267).
|
|
||||||
- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside
|
|
||||||
`scope:release-flow`. Both reconcilers share them, and a mixed file wears
|
|
||||||
both labels rather than `lib/**` being re-carved into a row per file (#267).
|
|
||||||
- TRIAGE.md now tells every epic author to put its progress checklist under
|
|
||||||
the literal `## Task list` heading, because any other heading is silently
|
|
||||||
invisible to the completion sweep (#266).
|
|
||||||
- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned
|
|
||||||
issue, while still directing triage to repair ownership instead (#264).
|
|
||||||
- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced
|
|
||||||
rather than as house style, beside the 300-character bound it now sits
|
|
||||||
next to (#262).
|
|
||||||
- Four fragments in flight gained a terminal citation; published sections
|
|
||||||
are untouched, so no shipped prose is re-opened (#262).
|
|
||||||
- BUILDER.md's green ruled term now names its field: greenness is read from
|
|
||||||
each check's `conclusion`, never its `status`, and *stale* means a check
|
|
||||||
of a superseded head — not a same-head node whose `status` lags its own
|
|
||||||
conclusion (#260).
|
|
||||||
- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`,
|
|
||||||
never a hardcoded list, so a new doctrine file propagates at the next
|
|
||||||
ordinary pin bump with zero list edits (#251).
|
|
||||||
- Define the doors-unchanged drill record and an executable release-path list,
|
|
||||||
so a release may reuse live evidence only when its door bytes are unchanged
|
|
||||||
since the last rehearsed tag (#237).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- A roster edit no longer reds the whole suite: the labels-reconcile
|
|
||||||
state-machine fixtures name their own panel instead of binding
|
|
||||||
`.github/labels.conf` by slot (#304).
|
|
||||||
- Shrinking `panel=` to three had left that binding's third slot unbound, and
|
|
||||||
`set -u` aborted the file before its first assertion — 217 assertions
|
|
||||||
became 0, on `main` and on every branch cut from it (#304).
|
|
||||||
- The one case still reading the shipped roster asserts a property, not a
|
|
||||||
size: it parses, and each member is recused from its own panel. Any
|
|
||||||
`panel=` of one or more members leaves `test/run.sh` green (#304).
|
|
||||||
- `lib/attention.sh` locates as label machinery beside its two shelf-mates —
|
|
||||||
`[scope:release-flow]` alone was a wrong answer of the class #267 measured
|
|
||||||
— and the map learns the sweep workflow pair, the shared-lib tests, and
|
|
||||||
seven enumerated test/guard surfaces (#302).
|
|
||||||
- Claiming a `needs-ruling` issue no longer buys its escalation another 7
|
|
||||||
quiet days: the issue-side ruling clock reads comments alone — an
|
|
||||||
assignment is the claim clock's fact — and LABELS.md now names what each
|
|
||||||
surface's clock reads (#284).
|
|
||||||
- `scope:release-flow` no longer rides every pull request: `changelog.d/**`
|
|
||||||
is out of its path map. Doctrine makes every behavior change write a
|
|
||||||
fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a
|
|
||||||
release surface. `CHANGELOG.md` stays, as only the release PR edits it
|
|
||||||
(#267).
|
|
||||||
- The issue-flow reconciler and its test now derive `scope:labels`, the scope
|
|
||||||
that already names the taxonomy they reconcile (#267).
|
|
||||||
- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257).
|
|
||||||
- The issue sweep no longer derives label writes from a read that failed. An
|
|
||||||
HTTP 504 whose body is GitHub's JSON error object passed every guard and
|
|
||||||
emptied the label set, so a healthy epic was written `needs-triage` and the
|
|
||||||
pass reported success (#247).
|
|
||||||
- A failed comments read no longer reclaims a live claim. Swallowed, it dated
|
|
||||||
the issue by `created_at` and unassigned the builder under a comment
|
|
||||||
asserting 48 hours of silence about an issue commented on seconds earlier
|
|
||||||
(#247).
|
|
||||||
- A failed comments read no longer reads as "no marker", which re-posted the
|
|
||||||
comment the marker exists to suppress (#247).
|
|
||||||
- Every read inside the per-issue subshell is checked explicitly, on its
|
|
||||||
status and on its payload shape; the issue is left exactly as it is and the
|
|
||||||
sweep continues. A partial pass names its skipped issues after
|
|
||||||
`reconciled.` (#247).
|
|
||||||
- A per-issue pass is now atomic: its writes and its log lines commit only
|
|
||||||
once the pass completes. A skip could previously land after an earlier
|
|
||||||
mutation, reporting an issue as untouched when a label had already been
|
|
||||||
written or removed (#247).
|
|
||||||
- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that
|
|
||||||
merged last, not the one numbered highest — merge order is not number order,
|
|
||||||
and the old rule spent the transition marker on the wrong PR (#242).
|
|
||||||
- Preserve active claims when an open local pull request links them with `Refs #N`. (#241).
|
|
||||||
- `blocker:unrequested` no longer fires while a head's checks are pending or
|
|
||||||
red: the review round forbids requesting there, so the one blocker that
|
|
||||||
demanded an act flagged builders for complying. Pending is CI's move, red is
|
|
||||||
`blocker:ci-red`'s (#236).
|
|
||||||
- `blocker:unrequested` now waits for the round to settle — the head and the
|
|
||||||
newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default
|
|
||||||
300s) — so a sweep landing between a push and its re-request no longer flags
|
|
||||||
a round in motion (#236).
|
|
||||||
- LABELS.md no longer claims nothing in `actions/` clears or reads
|
|
||||||
`attention`: the reconciler has done both since the derived `claimed` →
|
|
||||||
`post-merge` transition shipped. The amended text keeps the hand-set rule
|
|
||||||
and admits the one clear and the diagnostic read (#231).
|
|
||||||
- Triage now puts `attention` on the assigned issue that owns a claim, never
|
|
||||||
on its pull request, and treats an unassigned issue as a board bug rather
|
|
||||||
than a demand (#230).
|
|
||||||
|
|
||||||
## 0.5.0 — 2026-08-03
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `labels.conf` accepts optional `panel[<login>]=` rows: the required set for
|
|
||||||
a PR authored by that login is the row minus the author; other authors keep
|
|
||||||
`panel=`. Consumers gain the row at their next pin bump — adding it before
|
|
||||||
that bump is a parse failure that takes the label board down (#224).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Doctrine: third-party actions never hold a write-capable token by default —
|
|
||||||
repo-owned scripts in write-capable jobs, established publisher plus
|
|
||||||
full-SHA pin for the exception, SHA pins everywhere. Canonical in
|
|
||||||
REVIEWER.md, short form in BUILDER.md; consumers adopt at the pin bump
|
|
||||||
(#216).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `ruling_escalation_row` selects the setter's best-shaped in-window comment,
|
|
||||||
ties broken to the earliest, instead of the earliest outright — a whole-round
|
|
||||||
reply landing seconds before the escalation is no longer graded in its place
|
|
||||||
(crew#293).
|
|
||||||
- The escalation selector and `ruling_shape_decision` share one field-presence
|
|
||||||
matcher, and an undecodable body column scores 0 instead of erroring the
|
|
||||||
sweep.
|
|
||||||
- Five stale **unreleased** markers in `docs/CONSUMERS.md` now name their
|
|
||||||
tags: fragment mode, `changelog-assembled` and `runner-isolated` at
|
|
||||||
`0.2.0`; the additive labeler at `0.3.0`; the two-caller split at `0.4.1`
|
|
||||||
(#221).
|
|
||||||
- The marker convention now names its clearing owner: the release PR that
|
|
||||||
ships machinery clears, in that same PR, every marker its assembled
|
|
||||||
section makes false (#221).
|
|
||||||
- A standing non-approving verdict now outranks draft in `decide_state`: a
|
|
||||||
re-drafted PR mid-round reads `state:addressing`, a live panel request on a
|
|
||||||
draft surfaces as `state:bots-reviewing`, and a draft with no round history
|
|
||||||
still reads `state:building` (#205).
|
|
||||||
|
|
||||||
## 0.4.1 — 2026-08-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `forge_release_exists`, `forge_commit_pulls`, `forge_tag_create`,
|
|
||||||
`forge_release_create` and `forge_pr_create` on both backends, so the
|
|
||||||
release path names no client (#191).
|
|
||||||
|
|
||||||
- The forgejo backend serves one PR object at `/commits/{sha}/pull` where
|
|
||||||
GitHub serves an array at `/pulls`; both verbs emit the array shape, so
|
|
||||||
the call site carries one expression (#191).
|
|
||||||
|
|
||||||
- Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only,
|
|
||||||
so GitHub's ref-POST would have 404'd there forever (#191).
|
|
||||||
|
|
||||||
- `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates
|
|
||||||
it and every call reaches the network through there, so `repos//…` —
|
|
||||||
whose 404 reads as "no release" and "no PRs" — is now impossible (#191).
|
|
||||||
|
|
||||||
- Release asset names are percent-encoded. The hook contract permits any
|
|
||||||
filename, and the name travels as a query value: a space made curl reject
|
|
||||||
the URL and `&`/`#`/`+`/`%` silently renamed the asset (#191).
|
|
||||||
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
|
|
||||||
the runner's own environment, `forge_client` names the client it needs, and
|
|
||||||
`forge_preflight` refuses loudly before any sweep when the two disagree
|
|
||||||
(#188).
|
|
||||||
- The reconcilers and `labels-scope` run that preflight first, so a
|
|
||||||
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
|
|
||||||
sweep that reads nothing and reports success (#188).
|
|
||||||
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
|
|
||||||
`refs_references`, so "which issues does this PR close" is answered from a
|
|
||||||
PR body rather than from GitHub's GraphQL API (#188).
|
|
||||||
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
|
|
||||||
backends, selected by `forge_select`; no forge branching at the call sites
|
|
||||||
(#188).
|
|
||||||
- The forgejo backend proves each paginated gather complete against the
|
|
||||||
server's `x-total-count` and refuses loudly when it cannot — a missing
|
|
||||||
header is a refusal, not a pass (#188).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `docs/CONSUMERS.md`'s artifact-hook recovery no longer tells operators to
|
|
||||||
run `gh release create` by hand — on a Forgejo runner there is no `gh`.
|
|
||||||
It names the forge-neutral tag-door path first, with both clients shown
|
|
||||||
(#191).
|
|
||||||
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
|
|
||||||
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
|
|
||||||
replaced rather than translated; both forges return `number` and `body`
|
|
||||||
from `/pulls` in the same shape (#188).
|
|
||||||
|
|
||||||
- `forge_api` owns the page size, because each forge silently ignores the
|
|
||||||
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
|
|
||||||
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
|
|
||||||
|
|
||||||
- Outstanding review requests are derived from the reviews on the current head
|
|
||||||
rather than from `requested_reviewers`, which Forgejo never clears — read
|
|
||||||
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The release doors run on a Forgejo consumer. `lib/facts.sh` and
|
|
||||||
`release.yml` gathered and published through `gh`, which the runner image
|
|
||||||
does not ship, so the merge door read `labeled=no` for a correctly
|
|
||||||
labeled ceremony PR and the tag door died at the publish (#191).
|
|
||||||
|
|
||||||
- A release fact that could not be read is no longer reported as a definite
|
|
||||||
`no`. A completed read finding no label is still `no` and still
|
|
||||||
fail-closed; a read that did not complete refuses and emits no fact
|
|
||||||
(#191).
|
|
||||||
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
|
|
||||||
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
|
|
||||||
so `$label` is a syntax error there and every scope derivation died before
|
|
||||||
reading the config (#188).
|
|
||||||
|
|
||||||
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
|
|
||||||
consumer having read zero facts — measured on `heavy-duty/rig`, where the
|
|
||||||
sweep printed `reconciled.` over an empty PR list and scope reported "no
|
|
||||||
labeler.yml" for a file that exists (#188).
|
|
||||||
|
|
||||||
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
|
|
||||||
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
|
|
||||||
ruling ladder reads the same board on both forges (#188).
|
|
||||||
|
|
||||||
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
|
|
||||||
(HTTP 404); inline review comments come from reviews with
|
|
||||||
`comments_count > 0` (#188).
|
|
||||||
|
|
||||||
- CI installs shellcheck before linting, matching actionlint — the Forgejo
|
|
||||||
runner image does not ship it (#188).
|
|
||||||
|
|
||||||
## 0.4.0 — 2026-07-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
|
|
||||||
that pins the fragment set's shape and outranks the newest-published-section
|
|
||||||
inference; absent, the inference binds unchanged (#182).
|
|
||||||
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.
|
|
||||||
- `changelog_fragment_problem` bounds every entry at 300 normalized
|
|
||||||
characters, red on the PR that writes the fragment; the armed guard and
|
|
||||||
the assembler inherit the one definition (#167).
|
|
||||||
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
|
|
||||||
change ships several short entries, never one long one (#167).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Labels automation docs now make sweep cadence a consumer-owned tradeoff,
|
|
||||||
retain hourly as the engine-less default, and document manual dispatch as
|
|
||||||
the operator's immediate full-board sweep (#203).
|
|
||||||
- `labels` — the reconcile cron relaxes from `*/15` to hourly (#199), cutting a
|
|
||||||
private consumer's schedule-triggered full-board sweeps ~4× at GitHub's
|
|
||||||
1-minute billing floor.
|
|
||||||
- `labels` — the hourly cron is the sweep's only wake for transitions no
|
|
||||||
subscribed event carries — a verdict landing, blocker:ci-red, a
|
|
||||||
blocker:conflict when another PR merges, the time-based stale/reclaim — so it
|
|
||||||
bounds their latency to ≤1h, delaying no event-carried transition (#199).
|
|
||||||
- `labels` — the caller's `issues:` trigger narrows to
|
|
||||||
`[opened, closed, edited, reopened]` (#199), the actions that carry a
|
|
||||||
queue-state change the cron cannot wait a cadence for. The churn/validation
|
|
||||||
actions — labeled/unlabeled/assigned/unassigned — come off; the PR handoff
|
|
||||||
wake is unaffected.
|
|
||||||
- `labels` — each caller trigger now carries a comment saying why it is
|
|
||||||
subscribed, and reconcile keeps `cancel-in-progress: false` (#199) —
|
|
||||||
cancelling a sweep mid-board is the race that guard exists to prevent.
|
|
||||||
- `CONTRIBUTING.md` now points to `BUILDER.md` for the shared PR flow instead
|
|
||||||
of restating doctrine that can drift, while retaining ceremony's roster and
|
|
||||||
other repo-specific facts (#198).
|
|
||||||
- Builder doctrine makes each whole-round reply the durable Round log record
|
|
||||||
mirrored by the engine, leaving handoff as a mechanical facts-only step
|
|
||||||
instead of a newly composed summary (#196).
|
|
||||||
- `FLEET.md` removes its duplicate bench roster, records crew as a general
|
|
||||||
operator-configured tool, and advances its whole-file audit stamp to
|
|
||||||
`crew@eaeb302` with every surviving crew link re-pinned (#193).
|
|
||||||
- `FLEET.md` keeps the registry's authorization rule and its crew#16/crew#66
|
|
||||||
provenance, while replacing duplicated mechanism and path claims with a
|
|
||||||
pinned pointer to crew's registry header (#192).
|
|
||||||
- `BUILDER.md` gates both review-request points on a green check at the
|
|
||||||
head, carries crew#45's argued exception for failures outside the PR,
|
|
||||||
and states the ruled classification: cancelled and stale are not a
|
|
||||||
green head; skipped and neutral are (#189).
|
|
||||||
- `BUILDER.md` documents CI-red recovery in pickup precedence: a red head
|
|
||||||
of your own PR is picked up before claiming another issue, is never a
|
|
||||||
parked claim, and follows crew#17's recovery path (#189).
|
|
||||||
- `FLEET.md` writes the ci-red wake into the duty order between resume
|
|
||||||
and build, now as deployed engine rather than on paper: the
|
|
||||||
reconciliation stamp advances to the crew SHA carrying crew#64 (#189).
|
|
||||||
- `FLEET.md` describes the build wake's check gate as the engine
|
|
||||||
implements it: a green head, or one with no checks configured, opens a
|
|
||||||
round; a red head and an unfinished one are held and reported
|
|
||||||
separately (#189).
|
|
||||||
- `FLEET.md` corrects the attention wake to the crew#66 ruling: the query
|
|
||||||
is cross-repo, the action is registry-bounded, and an out-of-scope
|
|
||||||
demand is reported and escalated to the operator rather than worked. It
|
|
||||||
no longer claims attention is exempt from the registry (#189).
|
|
||||||
- `FLEET.md` distinguishes an attention session that dies before acking,
|
|
||||||
which relaunches, from one that completes without acking, which is a
|
|
||||||
decline a ledger keeps from re-firing (#189).
|
|
||||||
- `BUILDER.md` re-requests by head, not by verdict: a push while
|
|
||||||
answering a round stales every approval, so every panelist is
|
|
||||||
re-requested; only an unchanged head re-requests the non-approvers
|
|
||||||
alone (#190).
|
|
||||||
- FLEET.md's duty-loop mechanism is a pointer to crew's shared engine; the
|
|
||||||
wake lists follow the engine's duty order, the roster keeps the as-built
|
|
||||||
bench beside `fleet.roster`'s target, and the reconciliation stamp names
|
|
||||||
crew@`01fb49c` (#187).
|
|
||||||
- Ceremony's changelog is grouped from this release forward: the pending
|
|
||||||
fragments carry `### ` headings under a `grouped` sentinel (#182).
|
|
||||||
- BUILDER.md: a park declaration stands until its facts change — a
|
|
||||||
nothing-changed resumption posts nothing; only a no-open-PR park owes a
|
|
||||||
refresh, inside the 48-hour reclaim window (#178).
|
|
||||||
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- FLEET.md no longer says a review request outside the registry is
|
|
||||||
authorization: `repos.txt` is the scope for the review queue, out-of-scope
|
|
||||||
requests are logged and never acted on, and the attention wake is stated
|
|
||||||
as the one registry-independent exception, by design (#187).
|
|
||||||
- `blocked_reference_records` unions every `Blocked by` clause in the body
|
|
||||||
instead of binding to the first marker occurrence — a repeated declaration
|
|
||||||
no longer promotes on its first sentence alone, and earlier prose that
|
|
||||||
merely mentions being blocked no longer hijacks the parse (#184).
|
|
||||||
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
|
|
||||||
label stands — the PR falls to `state:addressing`, exactly parallel to the
|
|
||||||
`needs-ruling` exclusion; never emitted by `blockers()` (#180).
|
|
||||||
|
|
||||||
## 0.3.0 — 2026-07-24
|
## 0.3.0 — 2026-07-24
|
||||||
|
|
||||||
|
|
|
||||||
139
CONTRIBUTING.md
139
CONTRIBUTING.md
|
|
@ -10,22 +10,22 @@ the two is a bug.
|
||||||
Work moves through one pipeline, and every stage has an owner:
|
Work moves through one pipeline, and every stage has an owner:
|
||||||
|
|
||||||
```
|
```
|
||||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Proposals are where intent lives.** Anyone — human or agent — who has an
|
- **Discussions are where intent lives.** Anyone — human or agent — who has an
|
||||||
idea, a bug, a question, or a "we should…" files a **proposal**, not a work
|
idea, a bug, a question, or a "we should…" opens a **discussion**, not an
|
||||||
issue. Proposals are allowed to be vague; that is what they are for.
|
issue. Discussions are allowed to be vague; that is what they are for.
|
||||||
- **Work issues are minted only by triage.** Nobody else writes work issues —
|
- **Issues are minted only by triage.** Nobody else writes issues — not
|
||||||
not humans, not builders, not reviewers. A work issue is a work order with a quality
|
humans, not builders, not reviewers. An issue is a work order with a quality
|
||||||
bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds
|
bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds
|
||||||
because exactly one role is accountable for it. An issue that appears
|
because exactly one role is accountable for it. An issue that appears
|
||||||
through any other door gets `needs-triage` and is normalized or converted
|
through any other door gets `needs-triage` and is normalized or converted
|
||||||
back into a proposal.
|
back into a discussion.
|
||||||
- **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md).
|
- **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md).
|
||||||
- **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md).
|
- **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md).
|
||||||
- **Humans decide twice**: in the proposal (what is worth doing, and any
|
- **Humans decide twice**: in the discussion (what is worth doing, and any
|
||||||
call triage escalates back) and at the merge (whether it ships). Everything
|
call triage escalates back) and at the merge (whether it ships). Everything
|
||||||
between those two points is agent work by default.
|
between those two points is agent work by default.
|
||||||
- **Merging a release PR ships it** — the release ceremony this repo's
|
- **Merging a release PR ships it** — the release ceremony this repo's
|
||||||
|
|
@ -35,44 +35,68 @@ Who may set which label is [LABELS.md](LABELS.md)'s contract.
|
||||||
|
|
||||||
## The PR flow
|
## The PR flow
|
||||||
|
|
||||||
PRs move through review rounds that builders answer whole, and only a human
|
The same flow the sibling repos run, and the part of this pipeline that is
|
||||||
merges. [BUILDER.md](BUILDER.md) is the shared flow contract; this file names
|
already proven:
|
||||||
only ceremony-specific facts such as the roster and code conventions.
|
|
||||||
|
1. **One issue, one PR**, opened as a **draft** while building, with
|
||||||
|
`Closes #N` in the body — its exceptions (cross-repo work, a post-merge
|
||||||
|
criterion) live in [BUILDER.md](BUILDER.md). Drafts are invisible to the
|
||||||
|
reviewer panel on purpose. Every behavior change writes one fragment,
|
||||||
|
`changelog.d/<issue>.md` — the exact prose to publish, nothing else
|
||||||
|
(cross-repo work names it `<repo>-<issue>.md`; a grouped repo puts its
|
||||||
|
`### Added` / `### Changed` / `### Fixed` headings inside the fragment).
|
||||||
|
Never edit `CHANGELOG.md` for an entry — the release PR assembles the
|
||||||
|
section from the fragments (#112).
|
||||||
|
|
||||||
|
The sole exception is the release PR: it writes no fragment. It consumes
|
||||||
|
the directory and stamps the section, so a fragment it created would be
|
||||||
|
absent from
|
||||||
|
[`changelog-assembled`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-assembled/changelog-assembled.sh)'s
|
||||||
|
merge-base replay if consumed, or refused by
|
||||||
|
[`changelog-armed`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-armed/changelog-armed.sh)
|
||||||
|
if left to survive into the next release. A change that must ship inside
|
||||||
|
the release PR therefore ships without an entry. If it can wait and wants
|
||||||
|
an entry, land it as an ordinary PR before the release PR, then rebase and
|
||||||
|
re-assemble the release.
|
||||||
|
2. **When it's ready**: mark ready-for-review and request the whole panel.
|
||||||
|
3. **Rounds are answered whole.** Wait until every reviewer has a verdict in,
|
||||||
|
then answer the entire round in a **single reply**, push the fixes, and
|
||||||
|
re-request the reviewers that didn't approve. Prefer verification over
|
||||||
|
argument: a test settles what a comment thread can't.
|
||||||
|
4. **Reviews end in a verdict** — approve or request-changes, never a bare
|
||||||
|
comment. The verdict carries blockingness only; the body carries the
|
||||||
|
feedback. ([REVIEWER.md](REVIEWER.md) for why a comment-only review stalls
|
||||||
|
the machine.)
|
||||||
|
5. **Handoff**: when the round passes — every panel verdict is an approval of
|
||||||
|
the current head and no `blocker:*` label stands — the author posts the
|
||||||
|
round summary, requests the human's review, and sets `state:needs-human`.
|
||||||
|
The label write is optimistic; the reconciler validates it within seconds.
|
||||||
|
6. **A human merges.** Nothing else merges.
|
||||||
|
|
||||||
### Roster
|
### Roster
|
||||||
|
|
||||||
Four identities share the work (org team `agents`), each living in its own
|
Five identities share the work (org team `agents`), each living in its own
|
||||||
[box](https://github.com/heavy-duty/box) — one box per credential, because
|
[box](https://github.com/heavy-duty/box) — one box per credential, because
|
||||||
the box is the blast-radius boundary; roles are what a session is told, and
|
the box is the blast-radius boundary; roles are what a session is told, and
|
||||||
[AGENTS.md](AGENTS.md) routes from there:
|
[AGENTS.md](AGENTS.md) routes from there:
|
||||||
|
|
||||||
| identity | box (rig tenant) | standing work |
|
| identity | box (rig tenant) | standing work |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `claude-bot-andresmgsl` | `triage` (claude-box) | **triage** — the only door work issues come through; this identity mints work issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
|
| `dan-claude-bot` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) |
|
||||||
| `codex-bot-andresmgsl` | codex-box | build + review |
|
| `claude-bot-andresmgsl` | claude-box | build (release-flow and guards machinery) + review |
|
||||||
| `glm-bot-andresmgsl` | glm-box | review |
|
| `codex-bot-andresmgsl` | codex-box | build (scaffolding, conversions) + review |
|
||||||
| `kimi-bot-andresmgsl` | kimi-box | review |
|
| `grok-bot-andresmgsl` | grok-box | review |
|
||||||
|
| `kimi-bot-andresmgsl` | kimi-box | review — builder trial on a small mechanical issue once its verdicts have a track record |
|
||||||
|
|
||||||
**The review panel for any PR is every bench identity except its author** —
|
**The review panel for any PR is every bench identity except its author** —
|
||||||
recusal by construction, enforced by the reconciler (#10): the required
|
recusal by construction, enforced by the reconciler (#10): the required
|
||||||
verdicts are the panel minus the PR's author. On this roster that resolves
|
verdicts are the panel minus the PR's author, so convergence always means
|
||||||
to **three** cross-vendor approvals of the current head, because the only
|
three cross-vendor approvals of the current head. Builders and triage
|
||||||
builder is itself on the panel and recuses from its own PRs; the rule is
|
default to different models so the issue contract is honestly exercised —
|
||||||
panel-minus-author, and three is what it currently comes to, not a second
|
a spec gap should surface as a question on the issue, not be silently filled
|
||||||
rule. Builders and triage default to different models so the issue contract
|
by shared priors. Humans (`danmt`) decide in discussions and merge; the
|
||||||
is honestly exercised — a spec gap should surface as a question on the
|
roster is config, not doctrine — swapping a vendor is an edit to this table
|
||||||
issue, not be silently filled by shared priors. Humans (`andres`) decide in
|
(and to `panel=` in `.github/labels.conf` once #10 lands), nothing more.
|
||||||
proposals and merge; the roster is config, not doctrine — swapping a
|
|
||||||
vendor is an edit to this table (and to `panel=` in
|
|
||||||
`.github/labels.conf` once #10 lands), nothing more.
|
|
||||||
|
|
||||||
The identities named here must be the identities `.github/labels.conf`
|
|
||||||
names, and both must exist on the forge the repo lives on. A roster that
|
|
||||||
agrees with itself and disagrees with the instance is the failure #195
|
|
||||||
records: `panel=` naming absent users cannot converge and
|
|
||||||
`triage-actors=` naming an absent user makes every issue a stray mint that
|
|
||||||
nobody can normalize. `test/labels.test.sh` holds this table and the conf
|
|
||||||
to the same set, in both directions.
|
|
||||||
|
|
||||||
Each governed repo names its own roster in its CONTRIBUTING; this one is
|
Each governed repo names its own roster in its CONTRIBUTING; this one is
|
||||||
ceremony's. Its `scope:*` set is the same kind of repo-specific fact:
|
ceremony's. Its `scope:*` set is the same kind of repo-specific fact:
|
||||||
|
|
@ -95,35 +119,23 @@ table repeats it (#104).
|
||||||
- Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`.
|
- Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`.
|
||||||
- Shellcheck- and actionlint-clean is a CI gate, not a suggestion.
|
- Shellcheck- and actionlint-clean is a CI gate, not a suggestion.
|
||||||
|
|
||||||
## Doctrine conventions
|
|
||||||
|
|
||||||
The vendored role files — the set [`docs/VENDORED.txt`](docs/VENDORED.txt)
|
|
||||||
declares — state each normative rule completely, keep at most one sentence of
|
|
||||||
why, and cite its record only with a bare parenthetical such as `(#N)`,
|
|
||||||
`(#N D3)`, or `(#N, #M)`. Incident narrative — timestamps, actors, quoted
|
|
||||||
comments, measured counts, and links to specific comments — belongs in that
|
|
||||||
record. If a rule cannot be followed without chasing its cite, the rule is
|
|
||||||
under-stated: fix the statement, not the citation. (#280)
|
|
||||||
|
|
||||||
Normative text in those files does not cite issues from other repositories.
|
|
||||||
Consumers read the vendored bytes outside this organization's context, and a
|
|
||||||
cited repository may not be public. A repo-boundary deferral remains allowed:
|
|
||||||
it names another component as the owner of a fact rather than citing one of
|
|
||||||
that component's issues. (#280)
|
|
||||||
|
|
||||||
This is distinct from the code-comment convention above: a code comment is
|
|
||||||
read by a maintainer inside the organization while standing in the file,
|
|
||||||
whereas vendored doctrine is read by any agent in any governed repository on
|
|
||||||
every session. (#280)
|
|
||||||
|
|
||||||
## How the other repos use this
|
## How the other repos use this
|
||||||
|
|
||||||
Two consumption modes, split by what has a runtime: **machinery by
|
Two consumption modes, split by what has a runtime:
|
||||||
reference**, fetched at run time from the ref a caller pins, and **doctrine
|
|
||||||
as a mirror** — the set [`docs/VENDORED.txt`](docs/VENDORED.txt) declares,
|
- **Machinery is consumed by reference.** Workflows and actions are fetched
|
||||||
vendored at `.ceremony/` and held to the pin by a guard (issue #19). The
|
by GitHub at run time from the ref the caller pins — no copy exists in the
|
||||||
[README](README.md) states both modes in full, and why they differ; what
|
consumer.
|
||||||
follows is only what they leave a governed repo to carry.
|
- **Doctrine is consumed as a machine-verified mirror.** A document's only
|
||||||
|
"runtime" is an agent reading the working tree of the repo it stands in —
|
||||||
|
a doc that requires a cross-repo fetch before it governs is a doc that
|
||||||
|
sometimes goes unread. So the agent-facing set — **AGENTS.md, TRIAGE.md,
|
||||||
|
BUILDER.md, REVIEWER.md, LABELS.md** — is vendored into each governed
|
||||||
|
repo at **`.ceremony/`**, byte-identical to this repo at the pinned ref,
|
||||||
|
by the sync tool (issue #19). A CI guard diffs the mirror against the pin
|
||||||
|
on every PR: hand-editing a vendored file, or bumping the pin without
|
||||||
|
re-syncing, goes red. It is a copy that cannot drift — which is the only
|
||||||
|
kind of copy this org allows.
|
||||||
|
|
||||||
A governed repo (box, rig, cast, incubator, …) therefore carries:
|
A governed repo (box, rig, cast, incubator, …) therefore carries:
|
||||||
|
|
||||||
|
|
@ -143,8 +155,7 @@ A governed repo (box, rig, cast, incubator, …) therefore carries:
|
||||||
- the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`),
|
- the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`),
|
||||||
- the **drill meaning** (`drills/README.md`),
|
- the **drill meaning** (`drills/README.md`),
|
||||||
- the repo's own code conventions;
|
- the repo's own code conventions;
|
||||||
- **An intake door is open**: install the proposal form and `needs-triage`
|
- **Discussions enabled**, so the triage door exists.
|
||||||
flow, or use a forge-native intake surface.
|
|
||||||
|
|
||||||
One pin governs both the machinery and the doctrine: the ref a repo's
|
One pin governs both the machinery and the doctrine: the ref a repo's
|
||||||
workflows call is the ref its `.ceremony/` mirror is verified against.
|
workflows call is the ref its `.ceremony/` mirror is verified against.
|
||||||
|
|
|
||||||
312
FLEET.md
312
FLEET.md
|
|
@ -1,67 +1,54 @@
|
||||||
# FLEET.md — the fleet shape, and how it actually runs
|
# FLEET.md — the roster, and how it actually runs
|
||||||
|
|
||||||
> **Status:** descriptive snapshot, not doctrine. This file records how the
|
> **Status:** descriptive snapshot, not doctrine. This file records how the
|
||||||
> heavy-duty operator fleet is wired *today*. It is **not** part of the
|
> agent fleet that builds this repo is wired *today*, so the setup can later be
|
||||||
> vendored doctrine set (`.ceremony/`) and is never mirrored to consumer
|
> solidified into a replicable fleet-management solution. It is **not** part of
|
||||||
> repos. [Crew](https://github.com/heavy-duty/crew) is a general tool: its
|
> the vendored doctrine set (`.ceremony/`) and is never mirrored to consumer
|
||||||
> repository ships the engine, while the fleet definition belongs to the
|
> repos. The doctrine files (AGENTS.md, TRIAGE.md, BUILDER.md, REVIEWER.md,
|
||||||
> operator; heavy-duty is one operator of it. Membership, repository scope,
|
> LABELS.md, CONTRIBUTING.md) say what roles *must* do; this file says how the
|
||||||
> agent-profile overrides and doctrine paths belong to that definition;
|
> current bench *physically* does it. Last reconciled against the deployed
|
||||||
> membership itself lives outside every checkout. Crew's shipped defaults
|
> duty scripts at
|
||||||
> name heavy-duty's AGENTS.md, TRIAGE.md, BUILDER.md and REVIEWER.md, but
|
> [`heavy-duty/crew@b2fd864`](https://github.com/heavy-duty/crew/tree/b2fd8642e7f7aa8dc9de6b44edadbe1dc557b140)
|
||||||
> those are compatibility defaults, not vocabulary compiled into the engine
|
> (private to the org; the fleet can read it), 2026-07-24 — a descriptive
|
||||||
> — operator `doctrine.conf` values can replace them.
|
> file with no reconciliation stamp gives the next reader nothing to diff,
|
||||||
> The *mechanism* lives with crew and this file points at it. Last reconciled
|
> which is exactly how the #149 drift went unnoticed.
|
||||||
> against the merged engine at
|
|
||||||
> [`heavy-duty/crew@eaeb302`](https://github.com/heavy-duty/crew/tree/eaeb3022aa47d90e797f2b9e007b831df7ca8406),
|
|
||||||
> 2026-07-28 — a descriptive file with no reconciliation stamp gives the next
|
|
||||||
> reader nothing to diff, which is exactly how the #149 drift went unnoticed.
|
|
||||||
|
|
||||||
## Fleet shape
|
## The roster
|
||||||
|
|
||||||
One box (an isolated, disposable VM) per GitHub identity. Boxes are credential
|
One box (an isolated, disposable VM) per GitHub identity. Boxes are credential
|
||||||
boundaries; sessions inside a box are role boundaries. No box has an inbound
|
boundaries; sessions inside a box are role boundaries. No box has an inbound
|
||||||
network path — GitHub is the only queue. Fleet membership is the operator's
|
network path — GitHub is the only queue.
|
||||||
definition and lives outside every checkout; this file deliberately carries
|
|
||||||
no second roster.
|
|
||||||
|
|
||||||
Review panel per PR = the governed repo's `.github/labels.conf` `panel=` line
|
| Identity | Box | CLI | Roles |
|
||||||
minus the PR's author, as [REVIEWER.md](REVIEWER.md) specifies (recusal by
|
|---|---|---|---|
|
||||||
|
| `dan-claude-bot` | triage-box | Claude Code | **triage** — the only issue-minter |
|
||||||
|
| `claude-bot-andresmgsl` | claude-box | Claude Code | builder (hard machinery) + reviewer |
|
||||||
|
| `codex-bot-andresmgsl` | codex-box | Codex CLI | builder (mechanical) + reviewer |
|
||||||
|
| `grok-bot-andresmgsl` | grok-box | Grok CLI | reviewer |
|
||||||
|
| `kimi-bot-andresmgsl` | kimi-box | Kimi CLI | reviewer |
|
||||||
|
|
||||||
|
Review panel per PR = the reviewer bench minus the PR's author (recusal by
|
||||||
construction). Only humans merge — enforced as permissions (the agents team
|
construction). Only humans merge — enforced as permissions (the agents team
|
||||||
holds the triage role, not write), not as convention.
|
holds the triage role, not write), not as convention.
|
||||||
|
|
||||||
## Anatomy of a duty loop
|
## Anatomy of a duty loop
|
||||||
|
|
||||||
The mechanism is no longer described here. The five hand-rolled duty scripts
|
Every box runs the same skeleton, adapted to its CLI:
|
||||||
converged into crew's shared engine, and a prose mirror of running code in a
|
|
||||||
second repo is a second thing to keep true — this one drifted (it said cron
|
|
||||||
ran `duty.sh` directly and gave the hygiene sweep its own cron line; crew's
|
|
||||||
`duty.sh` records that separate line as the bug it fixed, sharing
|
|
||||||
`~/duty/work` unlocked). How a tick actually works — cron fires
|
|
||||||
[`bin/tick.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/tick.sh),
|
|
||||||
the only cron target, which wraps
|
|
||||||
[`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)
|
|
||||||
in a non-blocking `flock` with one evidence line per boundary; the boot gate
|
|
||||||
and crash recovery; the session runner; backlog hygiene self-scheduling
|
|
||||||
inside the duty tick under the same lock — lives with the code:
|
|
||||||
[`shared/README.md`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/README.md)
|
|
||||||
is the map, provenance table included. Sessions stay disposable: durable work
|
|
||||||
state lives on the board (issues, PRs, labels) and in git branches, while the
|
|
||||||
engine keeps only operational evidence and deduplication state under
|
|
||||||
`~/duty`; detection is the engine's, judgment is the session's.
|
|
||||||
|
|
||||||
What belongs here is what a wake *means*:
|
- **Tick:** cron `*/5` runs `~/duty/duty.sh` under a non-blocking `flock`; the
|
||||||
|
triage box adds an hourly hygiene sweep under its own lock. Holding the lock
|
||||||
- **The registry is the scope.** A box acts only on repos its operator
|
is load-bearing: a tick that acquires it *knows* nothing else is running on
|
||||||
listed. Work it finds outside that scope is reported and never acted on;
|
this identity.
|
||||||
the report is part of the boundary, because a bounded wake that goes quiet
|
- **Poll:** the script reads `~/duty/repos.txt` and queries GitHub with `gh`
|
||||||
is indistinguishable from a broken one. Adding a repo is an **operator
|
for work matching the box's role. Whose registry that file is depends on
|
||||||
decision**, never something a sweep makes by writing where nobody listed.
|
the role: the triage box's `repos.txt` **is** its registry — adding a repo
|
||||||
The 2026-07-25 scope ruling (crew#16) closed the org-wide review and
|
is adding a line — while a reviewer's registry is the org itself, and its
|
||||||
author-side write surface; the crew#66 attention ruling closed the last
|
`repos.txt` is a backstop that cannot scope it (grok's copy says so in its
|
||||||
exemption. Crew's
|
own first line).
|
||||||
[`examples/repos.txt` header](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/examples/repos.txt)
|
- **Act:** when there is work, the script launches the box's CLI as a one-shot
|
||||||
is the pinned source for how that rule is implemented and reported.
|
session with a role prompt; the session does the work via `gh` as the box's
|
||||||
|
own identity, then exits. Sessions are stateless and disposable — all state
|
||||||
|
lives on the board (issues, PRs, labels) and in git branches.
|
||||||
|
|
||||||
### Wake conditions
|
### Wake conditions
|
||||||
|
|
||||||
|
|
@ -75,27 +62,9 @@ sibling agent outranks self-directed continuation, and it is frequently the
|
||||||
very thing that unparks the work resume would otherwise pick up. The query is
|
very thing that unparks the work resume would otherwise pick up. The query is
|
||||||
the authenticated-user endpoint —
|
the authenticated-user endpoint —
|
||||||
`gh api "/issues?filter=assigned&state=open&labels=attention"` — one call, no
|
`gh api "/issues?filter=assigned&state=open&labels=attention"` — one call, no
|
||||||
search index (the review queue below already records that the index lags) —
|
search index (the reviewer trigger below already records that the index
|
||||||
and it **sees** repos outside the operator's registry, because that endpoint
|
lags), and like the review-request trigger it reaches repos `~/duty/repos.txt`
|
||||||
takes no repo filter.
|
does not name.
|
||||||
|
|
||||||
**Seeing is not acting, and that is a ruling** (crew#66, danmt, 2026-07-27).
|
|
||||||
The wake used to work every row it saw, which for a builder meant a clone and
|
|
||||||
the full worktree and round rule set against a repo no operator had listed —
|
|
||||||
write authority outside the registry, and the one hole left in the
|
|
||||||
containment story. Rows are now partitioned against the registry: inside it,
|
|
||||||
a session as before; outside it, reported and never acted on, exactly like an
|
|
||||||
out-of-scope review request or authored PR.
|
|
||||||
[`lib/duty-attention.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/lib/duty-attention.sh)
|
|
||||||
implements the partition and states the ruling in its header.
|
|
||||||
|
|
||||||
The cost was argued before the ruling rather than discovered after it: an
|
|
||||||
assignment plus a label **is** a targeted authorization, so a cross-repo
|
|
||||||
handoff now waits on an operator adding the repo, and the box most likely to
|
|
||||||
be handed work outside its beat is the one that goes quiet. That is why an
|
|
||||||
out-of-scope demand does not only reach `duty.log` — it pings the operator
|
|
||||||
over the same channel the boot gate uses. A bounded wake that failed silently
|
|
||||||
would trade an unbounded write surface for a broken channel to the human.
|
|
||||||
|
|
||||||
Each demand gets **exactly one session, and the ack bounds it**: the
|
Each demand gets **exactly one session, and the ack bounds it**: the
|
||||||
session's first act, before any of the demanded work, is the pickup comment
|
session's first act, before any of the demanded work, is the pickup comment
|
||||||
|
|
@ -103,13 +72,9 @@ plus removing the label — [the `attention`
|
||||||
contract's](https://github.com/heavy-duty/ceremony/blob/bce09aa7648dbd74b8e91b1d4fbc2fa8d145f705/LABELS.md#L143-L149)
|
contract's](https://github.com/heavy-duty/ceremony/blob/bce09aa7648dbd74b8e91b1d4fbc2fa8d145f705/LABELS.md#L143-L149)
|
||||||
ack (#85), which here becomes the session's ack-then-act ordering.
|
ack (#85), which here becomes the session's ack-then-act ordering.
|
||||||
Then it acts on the thread and exits — short by construction. Until the label
|
Then it acts on the thread and exits — short by construction. Until the label
|
||||||
is removed the flag is still up, so a session that **dies** before acking is
|
is removed the flag is still up, so a session that dies before acking is
|
||||||
simply relaunched at the next tick — the same crash-only shape as resume
|
simply relaunched at the next tick; that is the whole crash-recovery story,
|
||||||
below. A session that **completes** without acking is a different fact: that
|
and it is the same crash-only shape as resume below.
|
||||||
is a decline, and a seen-ledger stops it re-firing until the issue moves.
|
|
||||||
Dying and declining used to look identical to the engine, which meant a
|
|
||||||
demand a session had considered and correctly left alone woke a new one every
|
|
||||||
tick forever.
|
|
||||||
|
|
||||||
The design this replaces was built and rejected: polling notifications for
|
The design this replaces was built and rejected: polling notifications for
|
||||||
`reason: mention` re-arms a thread on every comment, so ordinary round
|
`reason: mention` re-arms a thread on every comment, so ordinary round
|
||||||
|
|
@ -120,91 +85,47 @@ wake: [#16's 16:49Z
|
||||||
ruling](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
|
ruling](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
|
||||||
authorized the last open acceptance criterion on a `claimed` issue and sat
|
authorized the last open acceptance criterion on a `claimed` issue and sat
|
||||||
unowned for over an hour — the box answered every state signal that day and
|
unowned for over an hour — the box answered every state signal that day and
|
||||||
never saw the comment, and the eventual pickup ran on a manual bridge. The
|
never saw the comment, and the eventual pickup ran on a manual bridge. Like
|
||||||
wake is no longer on paper: `duty-attention.sh` is deployed engine, and
|
the notifier's queue below, this wake is the spec for a box-side change only
|
||||||
`duty.sh` runs it first on every box, whatever its roles.
|
the operator can make; until `duty.sh` polls it, the wake exists on paper —
|
||||||
|
though one consumer already polls for the label and no-ops while it is
|
||||||
|
absent, so the wiring can be verified live the day the row lands.
|
||||||
|
|
||||||
The engine's duty order is fleet-standard
|
- **Triage:** new discussions to mint from, builder questions on issues, stray
|
||||||
([`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)):
|
issues to reconcile, `@`-mentions, hourly hygiene (stale claims, label
|
||||||
**attention → triage signals → review queue → resume → ci-red → build →
|
invariants), and a `needs-ruling` standing **past 24h** — the ladder's last
|
||||||
handoff → rebase → worktree hygiene → backlog hygiene (hourly)** — attention
|
rung makes the option triage's to pick, and this wake list is where triage
|
||||||
role-independent and first, then each duty family the box's roles enable.
|
learns such an item exists (see the notifier section below).
|
||||||
Every position in that order is deployed engine at the stamped SHA:
|
- **Builders**, in priority order: **resume** (an open draft PR of mine, or a
|
||||||
[crew#64](https://github.com/heavy-duty/crew/pull/64) merged ci-red between
|
claimed issue with my `build/*` branch but no PR — possible only if a
|
||||||
resume and build, and `duty.sh`'s own header carries the same order.
|
previous session died mid-work), a `ready` issue to claim, a completed
|
||||||
The earlier form of this file folded handoff and rebase into the other
|
review round on my PR (act on whole rounds, never single verdicts), my PR
|
||||||
builder wakes; they are duties of their own.
|
fully approved (write the closing summary, flip to `state:needs-human`,
|
||||||
|
request the human), my PR `CONFLICTING` (rebase; never act on `UNKNOWN` —
|
||||||
- **Triage signals**, per registry repo: `needs-triage` issues,
|
post-merge flap).
|
||||||
queue-unlabeled strays, proposals without triage's voice, unread
|
- **Reviewers**, one candidate set from two merged sources. Source 1,
|
||||||
`@`-mentions (their own session), and `blocked` issues whose named blockers
|
authoritative: every open PR in the `heavy-duty` org **plus the named bot
|
||||||
have all landed — a lead the session verifies, never a label the engine
|
forks** that lists me in `requested_reviewers`, enumerated straight from
|
||||||
flips. Backlog hygiene (stale claims, label invariants) runs hourly,
|
the pulls API — never `gh search`, whose index lags (cast#143,
|
||||||
self-scheduled inside the duty tick. A `needs-ruling` standing **past
|
incubator#25 and box#164 each sat unreviewed behind it). A review request
|
||||||
24h** is still triage's to pick up — the ladder's last rung makes the
|
is authorization, so no repo filter may gate it. Source 2, backstop: the
|
||||||
option triage's to choose — but, like the notifier queue below, that
|
`repos.txt` poll for an open PR by someone else whose head I have not yet
|
||||||
detection row is on paper only today.
|
reviewed — it only **adds** candidates the sweep may have missed (an
|
||||||
- **Review queue**: one candidate set, enumerated from the pulls pages of
|
org-enumeration failure, say) and never concludes "nothing to do". The
|
||||||
every registry repo — object endpoints, never the search index for the
|
sources are merged and deduplicated by (repo, PR) **before** acting, not
|
||||||
queue itself, whose lag left cast#143, incubator#25 and box#164 sitting
|
run as sequential passes — the sequential shape double-announced on
|
||||||
unreviewed — filtered to PRs listing me in `requested_reviewers`, deduped
|
ceremony#32, when the request sweep and the repo-list poll each acted on
|
||||||
by (repo, PR) before acting (the sequential shape double-announced on
|
the same PR in one tick (operator protocol 2026-07-23) — and worked
|
||||||
ceremony#32), and worked oldest-first. One search-backed **awareness pass**
|
oldest-first. Unchanged: one verdict per head, deduplicated against my
|
||||||
per tick reports requests outside the registry and never acts on them —
|
own latest review's SHA.
|
||||||
the scope rule above. One verdict per head, deduplicated against my own
|
|
||||||
latest review's SHA; a re-request at an unchanged head is answered with an
|
|
||||||
auto-approve through the verdict gate rather than left as a stale blocker
|
|
||||||
(operator ruling 2026-07-23, ceremony#94).
|
|
||||||
- **Resume** (builders, checked before build): an open draft PR of mine, or
|
|
||||||
a `claimed` issue whose `build/*` branch exists on my fork with no open PR
|
|
||||||
— a session died between first push and PR creation. A branch whose PR
|
|
||||||
already **merged** is a post-merge wait, never resumed (#172,
|
|
||||||
incubator#55/#64).
|
|
||||||
- **ci-red** (builders): a non-draft PR of mine whose check at the current
|
|
||||||
head is failing. Evaluated before the build wake, so a red PR of mine
|
|
||||||
outranks a new claim — repairing my own red head comes ahead of new work
|
|
||||||
(ceremony#163: full-panel approvals at the head, mergeable, stranded on
|
|
||||||
a transient failure no wake covered). A round owed at a red head is
|
|
||||||
excluded from the build wake below but reported rather than silent, and
|
|
||||||
an unchanged red head goes quiet after one attempt, through the
|
|
||||||
`report_suppressed` path — suppressed, still said. A check that has not
|
|
||||||
finished is **not** a red head and wakes nothing here: nothing has failed
|
|
||||||
yet, so there is no investigation to launch. How a red head is detected
|
|
||||||
and kept quiet is the engine's mechanism, described in crew's
|
|
||||||
`shared/README.md`, not here.
|
|
||||||
- **Build**: a `ready` **unclaimed** issue (an assignee means mid-claim, not
|
|
||||||
pickable), or a completed review round on my PR — a changes-request with
|
|
||||||
no panel review request still outstanding; whole rounds, never single
|
|
||||||
verdicts, and never a round the check at its head does not support. The
|
|
||||||
wake admits a **green** head, and a head with **no checks configured** —
|
|
||||||
terminal, not transient, so holding there would retire the round rather
|
|
||||||
than delay it. It holds a **red** head (already woken ci-red above) and a
|
|
||||||
head whose check has **not finished** (opening the round there spends the
|
|
||||||
panel on a head that may go red — crew#45's measured cost — and it admits
|
|
||||||
itself a tick later once the check settles). Both holds are reported, not
|
|
||||||
swallowed, and they are reported *differently*: only one of them is the
|
|
||||||
author's own work to do.
|
|
||||||
- **Handoff**: a round of mine that converged — every panelist's latest
|
|
||||||
opinionated review approves the current head, no panel request
|
|
||||||
outstanding, mergeable right now, `state:needs-human` not already set.
|
|
||||||
Convergence is computed from `latestOpinionatedReviews`, never
|
|
||||||
`reviewDecision`, which stays empty without branch protection and silently
|
|
||||||
stalled rounds for a day (ceremony#26, #39).
|
|
||||||
- **Rebase**: my PR `CONFLICTING` — and only `CONFLICTING`; `UNKNOWN` is
|
|
||||||
GitHub's post-merge recompute flap and waits. A conflicting draft belongs
|
|
||||||
to resume.
|
|
||||||
- **Worktree hygiene**: a `build/*` worktree is removed only when its branch
|
|
||||||
has PR history and no PR on it remains open; a branch with no PR at all is
|
|
||||||
an in-flight claim and stays.
|
|
||||||
|
|
||||||
#### The operator notifier — the `needs-ruling` queue
|
#### The operator notifier — the `needs-ruling` queue
|
||||||
|
|
||||||
The operator notifier (`notify.sh`, a fleet singleton on the triage box; its
|
The operator notifier (`notify.sh`, on the triage box) watches open PRs
|
||||||
mechanism is crew's too) watches open PRs carrying `state:needs-human`. That
|
carrying `state:needs-human`. That poll never reads `needs-ruling`, which
|
||||||
poll never reads `needs-ruling`, which lives mostly on *issues* — so an
|
lives mostly on *issues* — so an escalation waits invisibly on the very human
|
||||||
escalation waits invisibly on the very human it names. Not hypothetical: on
|
it names. Not hypothetical: on 2026-07-23 alone, three escalations spent
|
||||||
2026-07-23 alone, three escalations spent their whole lives outside the
|
their whole lives outside the operator's view — [#16's fork-PR-workflows
|
||||||
operator's view — [#16's fork-PR-workflows
|
|
||||||
question](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5053302689)
|
question](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5053302689)
|
||||||
(raised 01:23Z, [ruled 09:24Z](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5056705884)
|
(raised 01:23Z, [ruled 09:24Z](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5056705884)
|
||||||
— eight hours in which the board showed a `claimed` issue indistinguishable
|
— eight hours in which the board showed a `claimed` issue indistinguishable
|
||||||
|
|
@ -214,12 +135,11 @@ and [epic #50's own 13:04Z
|
||||||
flag](https://github.com/heavy-duty/ceremony/issues/50#issuecomment-5058713181),
|
flag](https://github.com/heavy-duty/ceremony/issues/50#issuecomment-5058713181),
|
||||||
which surfaced only because a human happened to look. This file records how
|
which surfaced only because a human happened to look. This file records how
|
||||||
the fleet actually runs; that is why this wiring changed (#50 D16). The spec
|
the fleet actually runs; that is why this wiring changed (#50 D16). The spec
|
||||||
for the engine-side update:
|
for the box-side update:
|
||||||
|
|
||||||
- **The second query.** Alongside the `state:needs-human` PR poll, `notify.sh`
|
- **The second query.** Alongside the `state:needs-human` PR poll, `notify.sh`
|
||||||
polls **open issues and PRs labelled `needs-ruling`** across every repo in
|
polls **open issues and PRs labelled `needs-ruling`** across every repo in
|
||||||
`notify-repos.txt`, which is deliberately wider than the duty registry:
|
`~/duty/repos.txt`.
|
||||||
a cross-repo handoff is precisely what the operator cannot discover alone.
|
|
||||||
- **One tracked message per item, edited in place** — the same
|
- **One tracked message per item, edited in place** — the same
|
||||||
one-message-per-item discipline the PR poll already uses, so an aging
|
one-message-per-item discipline the PR poll already uses, so an aging
|
||||||
ruling reads as a **live queue**, not a feed. The message is removed when
|
ruling reads as a **live queue**, not a feed. The message is removed when
|
||||||
|
|
@ -244,46 +164,54 @@ Nothing box-side ever sets, clears, or decides `needs-ruling` (#50 D9, D15):
|
||||||
the notifier and triage's past-24h wake above *report and pick up* what the
|
the notifier and triage's past-24h wake above *report and pick up* what the
|
||||||
board already shows; the label itself moves only by the doctrine's hands.
|
board already shows; the label itself moves only by the doctrine's hands.
|
||||||
|
|
||||||
The duty engine is crew's shared tree, one source deployed to every box.
|
`~/duty/repos.txt` and the duty scripts live inside each box and are the
|
||||||
Specs written in this file have a record of becoming engine: the attention
|
operator's to change; this descriptive edit is the spec for those box-side
|
||||||
wake and the reviewers' request sweep both started here as paper (the sweep's
|
updates. The reviewers' request sweep is one such spec made real — deployed
|
||||||
org-wide form was then retired by the 2026-07-25 scope ruling), and the
|
on all four reviewer boxes since 2026-07-23 (the Reviewers wake above). The
|
||||||
builders' ci-red wake above is the latest: written here as paper while
|
notifier's `needs-ruling` queue is still on paper only: `notify.sh`'s one
|
||||||
crew#64 was open, engine at the stamped SHA. Two rows are still on paper, and
|
label filter today is `state:needs-human`.
|
||||||
both are `needs-ruling`: the notifier's queue — at that SHA, `notify.sh`'s
|
|
||||||
only label filter is `state:needs-human` — and triage's **past 24h**
|
### Resilience
|
||||||
detection row above, which the triage-signals bullet already marks. Earlier
|
|
||||||
counts here said "two" while silently excluding the second; naming them is
|
- **Boot gate:** each tick compares the kernel boot id
|
||||||
cheaper than a number that has to be recounted every time a wake lands.
|
(`/proc/sys/kernel/random/boot_id`) to a stored marker. First tick after any
|
||||||
|
reboot runs credential + disk probes; the marker is written only when auth
|
||||||
|
actually works, so a box with dead credentials re-checks loudly every tick
|
||||||
|
instead of silently skipping duty.
|
||||||
|
- **Crash-only resume:** there is no session state to restore. The recovery
|
||||||
|
path *is* the normal path: the resume wake condition reads the board, posts
|
||||||
|
`⟲ resuming from <sha>`, and continues from the worklog. Rebooting a box
|
||||||
|
never loses work that was pushed.
|
||||||
|
- **Checkpoint discipline (builders):** open the PR as draft at the first
|
||||||
|
commit with a `## Worklog` checkbox list; check off and push after every
|
||||||
|
step. The board and the branch are the only memory.
|
||||||
|
- **Worktree isolation:** builders build each PR in its own `git worktree`;
|
||||||
|
reviewers check out PR heads in throwaway detached worktrees and remove them
|
||||||
|
after the verdict. Main clones stay parked on the default branch, always
|
||||||
|
clean; stale worktrees are pruned by the boot gate.
|
||||||
|
|
||||||
### Conventions on the board
|
### Conventions on the board
|
||||||
|
|
||||||
- `🔎 reviewing head <sha>` — a reviewer announces work before starting, so
|
- `🔎 reviewing head <sha>` — a reviewer announces work before starting, so
|
||||||
liveness is visible instead of hoped for.
|
liveness is visible instead of hoped for.
|
||||||
- `⟲ resuming from <sha>` — a builder announces recovery after interruption;
|
- `⟲ resuming from <sha>` — a builder announces recovery after interruption.
|
||||||
there is no session state to restore, so the recovery path *is* the normal
|
|
||||||
path: read the board, continue from the worklog. Rebooting a box never
|
|
||||||
loses work that was pushed.
|
|
||||||
- Checkpoint discipline (builders): open the PR as draft at the first commit
|
|
||||||
with a `## Worklog` checkbox list; check off and push after every step.
|
|
||||||
The board and the branch are the only memory.
|
|
||||||
- Claim ritual: comment on the issue + self-assign + label flip, before any
|
- Claim ritual: comment on the issue + self-assign + label flip, before any
|
||||||
branch exists.
|
branch exists.
|
||||||
- Handoff: the author closes an approved PR's round with a summary comment,
|
- Handoff: the author closes an approved PR's round with a summary comment,
|
||||||
flips `state:needs-human`, and requests the human — merging is never the
|
flips `state:needs-human`, and requests the human — merging is never the
|
||||||
fleet's job.
|
fleet's job.
|
||||||
- Worktree isolation: builders build each PR in its own `git worktree`;
|
|
||||||
reviewers check out PR heads in throwaway detached worktrees and remove
|
|
||||||
them after the verdict. Main clones stay parked on the default branch,
|
|
||||||
always clean.
|
|
||||||
|
|
||||||
## Where this is going
|
## Where this is going
|
||||||
|
|
||||||
This wiring proved itself on day one (seven merged PRs, unanimous three-model
|
This wiring proved itself on day one (seven merged PRs, unanimous three-model
|
||||||
review convergence on #39, and a full-fleet crash recovery), and the plan it
|
review convergence on #39, and a full-fleet crash recovery). The plan:
|
||||||
carried has become **heavy-duty/crew** — a shared engine, CLI, operator
|
|
||||||
configuration model, real-host rehearsal and fixture tests — so standing up
|
1. Once the ceremony machinery is complete and adopted, each agent will be
|
||||||
a fleet is a bootstrap, not an archaeology dig. What remains is adoption:
|
asked to write a **detailed, replicable description of its own setup** —
|
||||||
crew#85 tracks the road to a `0.1.0` another operator can use without a fork.
|
cron lines, duty script, prompts, probes — as durable documentation.
|
||||||
Membership stays in the operator definition; this file remains the map of
|
2. Those five descriptions get converged into a **solidified fleet-management
|
||||||
what a wake means, and crew is the map of how it runs.
|
solution** (duty loops as reusable templates, likely living alongside the
|
||||||
|
rig templates registry), so standing up this roster on a new repo — or a
|
||||||
|
whole new fleet — is a bootstrap, not an archaeology dig.
|
||||||
|
|
||||||
|
Until then, this file is the map.
|
||||||
|
|
|
||||||
82
LABELS.md
82
LABELS.md
|
|
@ -3,8 +3,7 @@
|
||||||
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
||||||
differs per repo (each repo's `.github/labels.conf` names its actual
|
differs per repo (each repo's `.github/labels.conf` names its actual
|
||||||
surfaces); everything else below is core and identical everywhere, created by
|
surfaces); everything else below is core and identical everywhere, created by
|
||||||
the labels workflow's dispatch (which is also the operator's manual
|
the labels workflow's bootstrap dispatch (issue #10).
|
||||||
full-board reconcile sweep; issue #10).
|
|
||||||
|
|
||||||
Two state machines share the taxonomy: the **PR machine** (proven in
|
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||||
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||||
|
|
@ -17,7 +16,7 @@ and the reconciler recomputes it from GitHub's own facts.
|
||||||
|
|
||||||
| Label | Color | Waiting on |
|
| Label | Color | Waiting on |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) |
|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
|
||||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||||
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
|
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
|
||||||
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||||
|
|
@ -27,10 +26,8 @@ and the reconciler recomputes it from GitHub's own facts.
|
||||||
`state:needs-human` means exactly one thing — a human could merge this now —
|
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||||
so it requires zero blockers and head-current approvals; anything less and
|
so it requires zero blockers and head-current approvals; anything less and
|
||||||
the reconciler takes it back. The author sets it at handoff (the one
|
the reconciler takes it back. The author sets it at handoff (the one
|
||||||
hand-set state). On a same-repository head, the `labeled` event fires the
|
hand-set state); the `labeled` event fires the sweep that validates the
|
||||||
sweep that validates the write within seconds; on a fork head whose
|
write within seconds.
|
||||||
`pull_request_target` token is read-only, validation waits for the scheduled
|
|
||||||
sweep cadence (#241).
|
|
||||||
|
|
||||||
## PR blockers — what is in the way? (facts, as many as apply)
|
## PR blockers — what is in the way? (facts, as many as apply)
|
||||||
|
|
||||||
|
|
@ -51,51 +48,24 @@ strips it on sight).
|
||||||
|
|
||||||
| Label | Color | Means | Set by |
|
| Label | Color | Means | Set by |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||||
| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage |
|
|
||||||
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||||
|
|
||||||
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
||||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||||
`blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue
|
`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue
|
||||||
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
||||||
comments, unassigns the stale owner, and restores `ready`.
|
comments, unassigns the stale owner, and restores `ready`.
|
||||||
|
|
||||||
When a merged PR references a `claimed` issue with `Refs #N` and unchecked
|
|
||||||
criteria remain, the sweep moves the issue to `post-merge`, clears the
|
|
||||||
assignee, and comments with the remaining criteria verbatim. The comment says
|
|
||||||
that the claim is released and that triage owes a follow-up naming the owner
|
|
||||||
and wake condition for completion. Triage writes that full transition comment
|
|
||||||
in the same tick when it or the operator makes the move by hand. The sweep
|
|
||||||
never reclaims `post-merge`: weeks of quiet can be the state working. It does
|
|
||||||
make the quiet visible — after 7 days with no comment on the issue, the sweep
|
|
||||||
posts one nudge naming the triage actor, saying the wake evidence is owed and
|
|
||||||
linking the item. Only a comment resets that clock: label churn does not, and
|
|
||||||
neither does an assignment, which is the claim clock's fact and on this queue
|
|
||||||
state is the invalid composition flagged below. Which criterion starved is
|
|
||||||
prose the machine never judges; the link is the payload. Like the ruling nudge
|
|
||||||
it carries no idempotency marker on purpose — the comment is itself activity,
|
|
||||||
so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no
|
|
||||||
label.
|
|
||||||
|
|
||||||
`post-merge` never composes with `blocked`; the transition comment carries the
|
|
||||||
wait. It never composes with `attention`, because releasing the claim clears
|
|
||||||
the assignee and leaves nobody parked-for. An assigned `post-merge` issue is
|
|
||||||
flagged rather than repaired: a hand-assignment is intent. `needs-ruling`
|
|
||||||
still composes. When the remainder becomes buildable, triage moves
|
|
||||||
`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim
|
|
||||||
that work from current `main`; the original builder has no special standing,
|
|
||||||
and re-entry does not set `attention`.
|
|
||||||
|
|
||||||
## Cross-cutting (PRs and issues)
|
## Cross-cutting (PRs and issues)
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
| Label | Color | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||||
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) |
|
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
|
||||||
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
|
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
|
||||||
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
|
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
|
||||||
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
|
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
|
||||||
|
|
@ -156,11 +126,8 @@ label never removed — and a ruling with no real activity for 7 days draws a
|
||||||
comment-only nudge addressed to the decider, linking the escalation. The
|
comment-only nudge addressed to the decider, linking the escalation. The
|
||||||
nudge carries no marker on purpose: the comment is itself activity, so it
|
nudge carries no marker on purpose: the comment is itself activity, so it
|
||||||
resets its own window and never repeats within a quiet week. Label churn is
|
resets its own window and never repeats within a quiet week. Label churn is
|
||||||
never activity, or the sweep would reset itself — and each surface's clock
|
not activity — the clock reads comments, reviews and commits, or the sweep
|
||||||
reads what exists on it: on a pull request, comments, reviews and commits;
|
would reset itself.
|
||||||
on an issue, comments alone. An assignment is the claim clock's fact, not
|
|
||||||
the ruling's — claiming a flagged issue does not answer it, and buys the
|
|
||||||
escalation no quiet (#284).
|
|
||||||
|
|
||||||
`offsite` is issue-only and records that a claimed issue's deliverable lives
|
`offsite` is issue-only and records that a claimed issue's deliverable lives
|
||||||
in another repository, where a closing reference cannot make a local open PR
|
in another repository, where a closing reference cannot make a local open PR
|
||||||
|
|
@ -185,22 +152,10 @@ The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
|
||||||
with `needs-ruling`, and never substitutes for queue state. It pauses no
|
with `needs-ruling`, and never substitutes for queue state. It pauses no
|
||||||
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
|
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
|
||||||
unanswered `attention` is exactly the silence the 48-hour reclaim should
|
unanswered `attention` is exactly the silence the 48-hour reclaim should
|
||||||
take. It is hand-set: the machine never sets `attention`, never assigns
|
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
|
||||||
anyone to receive one, and never decides that one has been answered — the
|
reads, or validates it, and no reconciler enforces the assignee requirement.
|
||||||
assignee's removal is the only ack. It writes the label in exactly one
|
|
||||||
place, the derived `claimed` → `post-merge` transition below, and nowhere
|
|
||||||
else; where it reads the flag it reads it to diagnose. The PR sweep comments
|
|
||||||
when `attention` is put on a pull request, and the issue sweep comments when
|
|
||||||
it is put on an issue with no assignee. Both diagnoses leave the label and
|
|
||||||
assignees alone; the machine never infers the claim issue, decides that the
|
|
||||||
demand was answered, or repairs either malformed shape.
|
|
||||||
An `attention` issue without an assignee is therefore a board bug, not a
|
An `attention` issue without an assignee is therefore a board bug, not a
|
||||||
demand; anyone may assign it or remove the flag. It never composes with
|
demand; anyone may assign it or remove the flag.
|
||||||
`post-merge`, whose released claim has no assignee to answer the demand. The
|
|
||||||
one machine-clear exception is the derived `claimed` → `post-merge`
|
|
||||||
transition: releasing the assignee clears a carried `attention` in the same
|
|
||||||
edit. A hand-created `post-merge` + `attention` composition is flagged, not
|
|
||||||
rewritten.
|
|
||||||
|
|
||||||
The three signals are mutually distinct: `attention` means an assignee owes
|
The three signals are mutually distinct: `attention` means an assignee owes
|
||||||
a move; `needs-ruling` means a human owes a decision under
|
a move; `needs-ruling` means a human owes a decision under
|
||||||
|
|
@ -233,13 +188,10 @@ on a PR would say the same thing twice and drift.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
The labels workflow (issue #10) recomputes PR state statelessly on subscribed
|
The labels workflow (issue #10) recomputes PR state statelessly on PR events
|
||||||
events plus a consumer-owned scheduled discovery sweep. Hourly is the
|
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
|
||||||
recommended default when no other engine drives board state; relax it only as
|
manual dispatch. The sweep warns when the core taxonomy declares a label the
|
||||||
the transition classes with no other writer shrink. Manual dispatch both
|
repository lacks. The same workflow reconciles issue-flow labels on issue
|
||||||
bootstraps this taxonomy idempotently and runs the operator's on-demand
|
|
||||||
full-board reconcile. The sweep warns when the core taxonomy declares a label
|
|
||||||
the repository lacks. The same workflow reconciles issue-flow labels on issue
|
|
||||||
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
||||||
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
||||||
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.
|
deleted at bootstrap — a `question` is a discussion, not an issue.
|
||||||
|
|
|
||||||
632
README.md
632
README.md
|
|
@ -1,66 +1,15 @@
|
||||||
# ceremony
|
# ceremony
|
||||||
|
|
||||||
The heavy-duty family's **governance repo**: the machinery every repo in the
|
One release ceremony for the whole heavy-duty family — implemented once,
|
||||||
family runs, and the doctrine every agent in the family reads. Implemented
|
tested once, documented here, consumed everywhere else by reference. The
|
||||||
once here, tested once here, consumed everywhere else — the machinery never
|
approach and its constraints live in
|
||||||
copied at all, the doctrine only as a mirror a guard keeps byte-identical to
|
[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the
|
||||||
the pin.
|
operator-facing doctrine that used to live, three times over, in the
|
||||||
|
consumers' CONTRIBUTINGs.
|
||||||
|
|
||||||
Two kinds of thing live in this tree, and they are consumed in two different
|
- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md).
|
||||||
ways because they have two different runtimes.
|
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you;
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics.
|
||||||
**Machinery is consumed by reference, at a pin.** The reusable workflows in
|
|
||||||
[`.github/workflows/`](.github/workflows/) and the composite actions in
|
|
||||||
[`actions/`](actions/) are fetched by GitHub at run time from the ref the
|
|
||||||
caller pins; no copy exists in the consumer. That machinery is two systems.
|
|
||||||
The **release ceremony** — [`release.yml`](.github/workflows/release.yml),
|
|
||||||
the decision and fact libraries under [`lib/`](lib/), and the guard actions
|
|
||||||
that keep a release honest — is the operator-facing half, and the runbook
|
|
||||||
below is its documentation. The **label and issue-flow machine** —
|
|
||||||
[`labels.yml`](.github/workflows/labels.yml) and its detached sweep half
|
|
||||||
[`labels-sweep.yml`](.github/workflows/labels-sweep.yml) (split in #209),
|
|
||||||
driving [`labels-scope`](actions/labels-scope/),
|
|
||||||
[`labels-reconcile`](actions/labels-reconcile/) and
|
|
||||||
[`issueflow-reconcile`](actions/issueflow-reconcile/) — converges PR state
|
|
||||||
and the issue work queue. What its labels *mean* is
|
|
||||||
[LABELS.md](LABELS.md)'s contract, not this page's.
|
|
||||||
|
|
||||||
**Doctrine is consumed as a machine-verified mirror.** A document's only
|
|
||||||
runtime is an agent reading the working tree it stands in, and a doc that
|
|
||||||
needs a cross-repo fetch before it governs is a doc that sometimes goes
|
|
||||||
unread. So the agent-facing set — the files named in
|
|
||||||
[`docs/VENDORED.txt`](docs/VENDORED.txt) — is vendored into each governed
|
|
||||||
repo at `.ceremony/`, byte-identical to this repo at the pinned ref, by
|
|
||||||
[`actions/docs-sync`](actions/docs-sync/). A CI guard diffs the mirror
|
|
||||||
against the pin on every PR: hand-editing a vendored file, or bumping the
|
|
||||||
pin without re-syncing, goes red. It is a copy that cannot drift, which is
|
|
||||||
the only kind of copy this org allows. This README is deliberately *not* in
|
|
||||||
that set — a consumer's router is its `AGENTS.md`, not this repo's front
|
|
||||||
page — and [`.github/scripts/vendored-check.sh`](.github/scripts/vendored-check.sh)
|
|
||||||
records that reason beside the three other ceremony-only root docs.
|
|
||||||
|
|
||||||
**One pin governs both halves.** The ref a repo's workflow callers name is
|
|
||||||
the ref its `.ceremony/` mirror is verified against, so a process change
|
|
||||||
rolls out as one reviewed PR per repo: the pin line plus the re-synced
|
|
||||||
mirror, checked by the same guard.
|
|
||||||
|
|
||||||
## Where to go
|
|
||||||
|
|
||||||
- **Adopting ceremony, or converting a repo that carries its own copy** →
|
|
||||||
[docs/CONSUMERS.md](docs/CONSUMERS.md) — the bootstrap and conversion
|
|
||||||
checklists, the caller stubs, the pin-bump procedure.
|
|
||||||
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you
|
|
||||||
to your role file ([TRIAGE.md](TRIAGE.md), [BUILDER.md](BUILDER.md),
|
|
||||||
[REVIEWER.md](REVIEWER.md)); [CONTRIBUTING.md](CONTRIBUTING.md) carries
|
|
||||||
this repo's own specifics — the review panel roster, the `scope:*` set,
|
|
||||||
the code and doctrine conventions.
|
|
||||||
- **The board: what a label means, and who may set it** →
|
|
||||||
[LABELS.md](LABELS.md). It is the shared state machine; misusing one label
|
|
||||||
lies to every other agent on the board.
|
|
||||||
- **Family release windows — what ships together, and when** →
|
|
||||||
[RELEASES.md](RELEASES.md).
|
|
||||||
- **How the operator fleet is actually wired** → [FLEET.md](FLEET.md), a
|
|
||||||
descriptive snapshot rather than doctrine.
|
|
||||||
- **Operating a release, or staring at a red run on main** → read on.
|
- **Operating a release, or staring at a red run on main** → read on.
|
||||||
|
|
||||||
## What a release is
|
## What a release is
|
||||||
|
|
@ -74,14 +23,13 @@ stamps:
|
||||||
([lib/version.sh](lib/version.sh)).
|
([lib/version.sh](lib/version.sh)).
|
||||||
2. **The changelog section is assembled — one edit, produced by the tool**
|
2. **The changelog section is assembled — one edit, produced by the tool**
|
||||||
(#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
|
(#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
|
||||||
fragment file, `changelog.d/<issue>.md`
|
fragment file, `changelog.d/<issue>.md`, and the ceremony PR runs
|
||||||
([the directory's marker](changelog.d/README.md) names the doctrine), and
|
[bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose,
|
||||||
the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) —
|
so the section lands in the PR's diff where the panel reads it (#112
|
||||||
by hand, on purpose, so the section lands in the PR's diff where the
|
D12; a consumer's exact invocation is in
|
||||||
panel reads it (#112 D12; a consumer's exact invocation is in
|
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)).
|
||||||
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The
|
The tool folds every fragment into a new `## X.Y.Z — DATE` section on
|
||||||
tool folds every fragment into a new `## X.Y.Z — DATE` section on top and
|
top and deletes the fragments it consumed; the
|
||||||
deletes the fragments it consumed; the
|
|
||||||
[assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments)
|
[assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments)
|
||||||
replays that run and refuses a stamp that is not byte-for-byte the
|
replays that run and refuses a stamp that is not byte-for-byte the
|
||||||
fragments' assembly.
|
fragments' assembly.
|
||||||
|
|
@ -90,82 +38,62 @@ stamps:
|
||||||
`## Unreleased` back on top — because every PR inserted at that one
|
`## Unreleased` back on top — because every PR inserted at that one
|
||||||
shared anchor, and between the stamp and the re-arm a PR authored
|
shared anchor, and between the stamp and the re-arm a PR authored
|
||||||
*before* the release landed its entry under whatever now occupied the
|
*before* the release landed its entry under whatever now occupied the
|
||||||
position — **the section that just shipped** — cleanly, no conflict, no
|
position — **the section that just shipped** — cleanly, no conflict,
|
||||||
signal (box#108; confirmed cross-repo as rig#66). Fragments make that
|
no signal (box#108; confirmed cross-repo as rig#66). Fragments make
|
||||||
failure structurally impossible rather than guarded-against: a fragment
|
that failure structurally impossible rather than guarded-against: a
|
||||||
merged after the release simply sits in the directory and is assembled
|
fragment merged after the release simply sits in the directory and is
|
||||||
into the *next* section. There is no anchor left to misplace, and nothing
|
assembled into the *next* section. There is no anchor left to misplace,
|
||||||
to re-arm — the directory is always armed.
|
and nothing to re-arm — the directory is always armed.
|
||||||
|
|
||||||
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
|
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
|
||||||
evidence the release rests on ([the drill doctrine](#the-drill-doctrine)).
|
evidence the release rests on
|
||||||
|
([the drill doctrine](#the-drill-doctrine)).
|
||||||
|
|
||||||
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the ref
|
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the
|
||||||
consumers' runs fetch this repo at — moves to the version being released, in
|
ref consumers' runs fetch this repo at — moves to the version being
|
||||||
[release.yml](.github/workflows/release.yml#L123-L132) and every other
|
released, in [release.yml](.github/workflows/release.yml#L123-L132) and
|
||||||
workflow that carries it.
|
every other workflow that carries it.
|
||||||
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not a
|
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not
|
||||||
consumer's release, when it is stale.)
|
a consumer's release, when it is stale.)
|
||||||
|
|
||||||
**The merge is the ship decision; the tag is transcription.** After the
|
**The merge is the ship decision; the tag is transcription.** After the
|
||||||
merge, [release.yml](.github/workflows/release.yml#L136-L310) asserts its
|
merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its
|
||||||
way to certainty, tags the merge commit, publishes the forge release with
|
way to certainty, tags the merge commit, publishes the GitHub release with
|
||||||
the version's own changelog section as the body — the curated prose, never
|
the version's own changelog section as the body — the curated prose, never
|
||||||
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
|
the generated PR list
|
||||||
canonical extractor, and [bin/changelog-section](bin/changelog-section) is
|
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) —
|
||||||
its command-line face) — and, on the bare-`X.Y.Z` path, re-arms main by
|
and re-arms main by bumping to `X.Y.(Z+1)-dev`
|
||||||
bumping to `X.Y.(Z+1)-dev`; the version is the only re-arm left, the
|
([release.yml](.github/workflows/release.yml#L266-L300)) — the version is
|
||||||
changelog needs none (#112). An rc ships too, and its next version is a human
|
the only re-arm left; the changelog needs none (#112). The machine does
|
||||||
decision rather than arithmetic, so the re-arm stops for you to make it
|
the transcription because humans err silently and machines fail loudly:
|
||||||
([The re-arm refused](#the-re-arm-refused-releaseyml)). The machine does the
|
|
||||||
transcription because humans err silently and machines fail loudly:
|
|
||||||
**everything asserts its way to certainty and fails loudly, creating
|
**everything asserts its way to certainty and fails loudly, creating
|
||||||
nothing** — a wrong release is worse than a missing one, so every assert in
|
nothing** — a wrong release is worse than a missing one, so every failed
|
||||||
this file fires *before its door creates anything*, and one that fails leaves
|
assert leaves zero artifacts: no tag, no release, no bump.
|
||||||
zero artifacts of the run's own: no tag it made, no release, no bump. Three
|
|
||||||
steps run past the tag, and what a failure at each leaves behind is what
|
|
||||||
sorts them. Two fail before the release exists: the consumer's
|
|
||||||
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
|
|
||||||
the publish, so its non-zero exit aborts, and the publish itself
|
|
||||||
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
|
|
||||||
can fail on the API call or the assets. Either leaves the same state — a tag
|
|
||||||
standing and no release — which the merge-door preflight recognizes and a
|
|
||||||
re-run resumes. The tag door remains the fallback when the original run is no
|
|
||||||
longer reachable or the release must come from a fixed tree. The third is the
|
|
||||||
re-arm, which runs after the publish, and its refusal is the single failure in
|
|
||||||
this file that leaves a real release behind.
|
|
||||||
|
|
||||||
## The two doors
|
## The two doors
|
||||||
|
|
||||||
- **The merge door — the paved road.** A push to main runs the
|
- **The merge door — the paved road.** A push to main
|
||||||
|
([release.yml](.github/workflows/release.yml#L140)) runs the
|
||||||
[decide table](#what-happens-when-my-pr-lands-on-main); a merged,
|
[decide table](#what-happens-when-my-pr-lands-on-main); a merged,
|
||||||
`release`-labeled PR whose version transitioned to bare is the ceremony,
|
`release`-labeled PR whose version transitioned to bare is the ceremony,
|
||||||
everything legitimate that isn't one is a green no-op, and every
|
everything legitimate that isn't one is a green no-op, and every
|
||||||
half-ceremony dies loudly
|
half-ceremony dies loudly. Use it for every normal release.
|
||||||
([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
|
|
||||||
normal release.
|
|
||||||
|
|
||||||
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
|
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag
|
||||||
— **no `v` prefix**, box's 0.6.0 set the scheme
|
push — **no `v` prefix**, box's 0.6.0 set the scheme
|
||||||
([release.yml](.github/workflows/release.yml#L325-L410)) — publishes the
|
([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the
|
||||||
same way. The tag is the operator's explicit act, so there is no decide
|
same way. The tag is the operator's explicit act, so there is no decide
|
||||||
and no label check — what is left is three asserts: **the tag names the
|
and no label check; the one assert is that **the tag names the tree's
|
||||||
tree's own version**
|
own version**, and a mismatch refuses, creating nothing. No `-dev` bump
|
||||||
([L350–L361](.github/workflows/release.yml#L350-L361)), **the tagged
|
either — the fallback does not rewrite main (cast's precedent). Use it
|
||||||
tree carries a publishable `## X.Y.Z` section**
|
when the merge path is red, for backfills, and for the
|
||||||
([L362–L374](.github/workflows/release.yml#L362-L374)), and **no published
|
|
||||||
release already exists for the tag**
|
|
||||||
([L375–L390](.github/workflows/release.yml#L375-L390)); any failure
|
|
||||||
refuses, creating nothing. No `-dev` bump either
|
|
||||||
— the fallback does not rewrite main (cast's precedent). Use it when the
|
|
||||||
merge path is red, for backfills, and for the
|
|
||||||
[first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4).
|
[first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4).
|
||||||
|
|
||||||
Tag + publish (+ the consumer's artifact hook) happen **in the same job, on
|
Tag + publish (+ the consumer's artifact hook) happen **in the same job,
|
||||||
purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's
|
on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's
|
||||||
anti-recursion), so the merge door's tag can never re-enter the tag door and
|
anti-recursion), so the merge door's tag can never re-enter the tag door
|
||||||
double-publish — and that job is the release's only chance to publish (#1
|
and double-publish — and that job is the release's only chance to publish
|
||||||
constraint 2).
|
([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2).
|
||||||
|
|
||||||
## What happens when my PR lands on main
|
## What happens when my PR lands on main
|
||||||
|
|
||||||
|
|
@ -173,8 +101,8 @@ The merge door runs on **every** push to main, and the `release` label
|
||||||
legitimately means two things (release ceremonies, and ordinary work *on*
|
legitimately means two things (release ceremonies, and ordinary work *on*
|
||||||
the release machinery), so the door's first act is a decision: the six-row
|
the release machinery), so the door's first act is a decision: the six-row
|
||||||
table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment
|
table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment
|
||||||
block *is* the spec, and the table is contract-tested offline by
|
block *is* the spec, and the table is contract-tested offline). Rendered
|
||||||
[test/decide.test.sh](test/decide.test.sh)). Rendered for operators:
|
for operators:
|
||||||
|
|
||||||
| # | the tree your merge produced | the run | what it means — and your move |
|
| # | the tree your merge produced | the run | what it means — and your move |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|
@ -183,11 +111,11 @@ block *is* the spec, and the table is contract-tested offline by
|
||||||
| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
|
| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
|
||||||
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). |
|
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). |
|
||||||
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
|
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
|
||||||
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. **Read *bare* as decide reads it** — anything not `-dev` ([lib/decide.sh](lib/decide.sh#L108-L110)) — so an rc transition is a shippable ceremony here too, and the release lands but the re-arm stops for you to pick the next version ([The re-arm refused](#the-re-arm-refused-releaseyml)). |
|
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. |
|
||||||
|
|
||||||
The green rows are the point as much as the red ones: the machinery must be
|
The green rows are the point as much as the red ones: the machinery must
|
||||||
safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op,
|
be safe to work on, so every legitimate non-ceremony is a green `NOTICE`
|
||||||
never a red run on main per infra PR
|
no-op — never a red run on main per infra PR
|
||||||
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
|
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
|
||||||
automation never guesses; the version transition is the interlock, and
|
automation never guesses; the version transition is the interlock, and
|
||||||
label-without-transition (row 4) and transition-without-label (row 5) both
|
label-without-transition (row 4) and transition-without-label (row 5) both
|
||||||
|
|
@ -195,54 +123,36 @@ refuse (#1 constraint 8).
|
||||||
|
|
||||||
## The guards
|
## The guards
|
||||||
|
|
||||||
[`actions/`](actions/) holds ten composite actions. Three belong to the
|
Four composite actions run in every consumer's CI (and in this repo's
|
||||||
label machine named above and are not the operator's business here. Of the
|
own). Shared shape: version-keyed where the tree's state matters, loud
|
||||||
remaining seven, a consumer's own `ci.yml` carries **five** guard steps —
|
where it fails, and **a file of its own so a test can drive it**. The full
|
||||||
`changelog-armed`, `changelog-monotonic`, `changelog-assembled`,
|
war stories are in the scripts' header comments — authoritative and longer
|
||||||
`drill-recorded` and [`runner-isolated`](actions/runner-isolated/), the last
|
than this; what follows is the operator's cut.
|
||||||
asserting that no `pull_request`-triggered workflow names a self-hosted
|
|
||||||
runner (#58) — plus [`refs-not-closing`](actions/refs-not-closing/) in its
|
|
||||||
own [`refs-guard.yml`](.github/workflows/refs-guard.yml) caller, because
|
|
||||||
body edits are load-bearing there (#200, #218), and
|
|
||||||
[`docs-sync`](actions/docs-sync/) once the repo adopts the agent team flow.
|
|
||||||
The exact steps and their pin-availability rules are in
|
|
||||||
[docs/CONSUMERS.md](docs/CONSUMERS.md).
|
|
||||||
|
|
||||||
The four below are the release's own, and this is the operator's cut of
|
|
||||||
them. Shared shape: version-keyed where the tree's state matters, loud where
|
|
||||||
it fails, and **a file of its own so a test can drive it**. The full war
|
|
||||||
stories are in the scripts' header comments — authoritative and longer than
|
|
||||||
this.
|
|
||||||
|
|
||||||
This repo eats what it serves: [`ci.yml`](.github/workflows/ci.yml) runs the
|
|
||||||
guard actions against its own real tree, and
|
|
||||||
[`release-exercise.yml`](.github/workflows/release-exercise.yml) replays the
|
|
||||||
merge door's step sequence on every PR.
|
|
||||||
|
|
||||||
### changelog-armed — main never sits disarmed
|
### changelog-armed — main never sits disarmed
|
||||||
|
|
||||||
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)),
|
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)),
|
||||||
keyed on the tree's shape, then its version. In **fragment mode** —
|
keyed on the tree's shape, then its version. In **fragment mode** —
|
||||||
`changelog.d/` exists, the arming property moved onto the directory (#112
|
`changelog.d/` exists, the arming property moved onto the directory
|
||||||
D7):
|
(#112 D7):
|
||||||
|
|
||||||
- always → the marker `changelog.d/README.md` must exist (what keeps the
|
- always → the marker `changelog.d/README.md` must exist (what keeps the
|
||||||
directory tracked when it holds no fragments), no `## Unreleased` section
|
directory tracked when it holds no fragments), no `## Unreleased`
|
||||||
may survive in `CHANGELOG.md` (a second anchor with no owner), and every
|
section may survive in `CHANGELOG.md` (a second anchor with no owner),
|
||||||
fragment must be publishable on its own — named `<issue>.md` or
|
and every fragment must be publishable on its own — named `<issue>.md`
|
||||||
`<repo>-<issue>.md`, no `## ` heading, at least one bullet, no `### `
|
or `<repo>-<issue>.md`, no `## ` heading, at least one bullet, no
|
||||||
heading without an entry. A malformed fragment fails the PR that wrote it,
|
`### ` heading without an entry. A malformed fragment fails the PR that
|
||||||
not the release that consumes it (#112 D9).
|
wrote it, not the release that consumes it (#112 D9).
|
||||||
- `-dev` tree → nothing more. The directory **is** the arming: the next PR's
|
- `-dev` tree → nothing more. The directory **is** the arming: the next
|
||||||
entry is a new file, and a new file always has somewhere to land.
|
PR's entry is a new file, and a new file always has somewhere to land.
|
||||||
- bare tree (the ceremony PR and its merge) → every fragment must be
|
- bare tree (the ceremony PR and its merge) → every fragment must be
|
||||||
consumed, and the top section must be the stamped, publishable section for
|
consumed, and the top section must be the stamped, publishable section
|
||||||
exactly that version. Fragment mode has no re-armed shape — there is
|
for exactly that version. Fragment mode has no re-armed shape — there
|
||||||
nothing left to re-arm.
|
is nothing left to re-arm.
|
||||||
|
|
||||||
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
|
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
|
||||||
verbatim; both shapes stay supported so a consumer adopts fragments on a pin
|
verbatim; both shapes stay supported so a consumer adopts fragments on a
|
||||||
bump, on its own schedule (#112 D8):
|
pin bump, on its own schedule (#112 D8):
|
||||||
|
|
||||||
- `-dev` tree → the top section **must** be `## Unreleased`.
|
- `-dev` tree → the top section **must** be `## Unreleased`.
|
||||||
- bare tree (the ceremony PR and its merge) → the top section may be
|
- bare tree (the ceremony PR and its merge) → the top section may be
|
||||||
|
|
@ -256,30 +166,32 @@ bump, on its own schedule (#112 D8):
|
||||||
uses, so the two cannot disagree about what a section is).
|
uses, so the two cannot disagree about what a section is).
|
||||||
|
|
||||||
**The incident**: box#108 / rig#66 — the silent mislanding described
|
**The incident**: box#108 / rig#66 — the silent mislanding described
|
||||||
[above](#what-a-release-is). Fragment mode retires the incident's mechanism
|
[above](#what-a-release-is). Fragment mode retires the incident's
|
||||||
outright; legacy mode guards it. **Red means** a PR entry has nowhere safe
|
mechanism outright; legacy mode guards it. **Red means** a PR entry has
|
||||||
to land — a missing marker, a surviving `## Unreleased`, a malformed
|
nowhere safe to land — a missing marker, a surviving `## Unreleased`, a
|
||||||
fragment — or a stamped version would publish no entries, a dangling grouped
|
malformed fragment — or a stamped version would publish no entries, a
|
||||||
heading, or a bare tree still carrying fragments the stamp did not consume
|
dangling grouped heading, or a bare tree still carrying fragments the
|
||||||
(`not consumed` — re-run the assembler); the message names the fix in every
|
stamp did not consume (`not consumed` — re-run the assembler); the
|
||||||
case. What this guard cannot see is a fragment that *was* consumed but whose
|
message names the fix in every case. What this guard cannot see is a
|
||||||
entry the stamp omits — the fragment is gone from HEAD, so only
|
fragment that *was* consumed but whose entry the stamp omits — the
|
||||||
|
fragment is gone from HEAD, so only
|
||||||
[changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s
|
[changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s
|
||||||
merge-base replay catches that loss.
|
merge-base replay catches that loss.
|
||||||
|
|
||||||
**Do not "simplify" this to "always require `## Unreleased`".** The
|
**Do not "simplify" this to "always require `## Unreleased`".** The
|
||||||
unconditional form is false by construction on the ceremony PR's own tree —
|
unconditional form is false by construction on the ceremony PR's own tree
|
||||||
it makes every release unshippable — and rig#44 and cast#108 both had to
|
— it makes every release unshippable — and rig#44 and cast#108 both had
|
||||||
revert exactly that. The version-keyed form is what rig and cast get back by
|
to revert exactly that
|
||||||
adopting this repo.
|
([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)).
|
||||||
|
The version-keyed form is what rig and cast get back by adopting this repo.
|
||||||
|
|
||||||
One consequence worth knowing before it happens, legacy mode only: a
|
One consequence worth knowing before it happens, legacy mode only: a
|
||||||
ceremony PR that stamps and forgets to re-arm still passes this guard — a
|
ceremony PR that stamps and forgets to re-arm still passes this guard — a
|
||||||
bare tree is allowed to be stamped. It goes red **the moment the automatic
|
bare tree is allowed to be stamped. It goes red **the moment the automatic
|
||||||
`-dev` bump lands on main**. The guard does not block the release; it
|
`-dev` bump lands on main**. The guard does not block the release; it
|
||||||
refuses to let main *sit* disarmed, which is the window a late PR falls
|
refuses to let main *sit* disarmed, which is the window a late PR falls
|
||||||
into. Fragment mode has no such window: with no re-arm step there is nothing
|
into. Fragment mode has no such window: with no re-arm step there is
|
||||||
to forget.
|
nothing to forget.
|
||||||
|
|
||||||
### changelog-assembled — the stamp is exactly the fragments
|
### changelog-assembled — the stamp is exactly the fragments
|
||||||
|
|
||||||
|
|
@ -287,138 +199,132 @@ to forget.
|
||||||
([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)):
|
([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)):
|
||||||
on a release PR in fragment mode, the stamped `## X.Y.Z` section must be
|
on a release PR in fragment mode, the stamped `## X.Y.Z` section must be
|
||||||
**byte-for-byte** what the fragments it consumed assemble to. The guard
|
**byte-for-byte** what the fragments it consumed assemble to. The guard
|
||||||
reads the fragments as of the merge base (they are gone from HEAD — that is
|
reads the fragments as of the merge base (they are gone from HEAD — that
|
||||||
the point of the ceremony), replays `changelog-assemble --check` over that
|
is the point of the ceremony), replays `changelog-assemble --check` over
|
||||||
set, and diffs the result against HEAD's section body. Every tree it does
|
that set, and diffs the result against HEAD's section body. Every tree it
|
||||||
not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes
|
does not apply to — a `-dev` tree, legacy mode, no consumed fragments —
|
||||||
with a green `NOTICE`, so a non-ceremony PR is never red here.
|
passes with a green `NOTICE`, so a non-ceremony PR is never red here.
|
||||||
|
|
||||||
**The failure it catches** (#116): assembly is a hand-run step by design —
|
**The failure it catches** (#116): assembly is a hand-run step by design —
|
||||||
the section must land in the PR's diff where the panel reads it (#112 D12) —
|
the section must land in the PR's diff where the panel reads it (#112 D12)
|
||||||
and a mis-run hand step can leave no trace. The two failure shapes differ,
|
— and a mis-run hand step can leave no trace. The two failure shapes
|
||||||
and the guards split them exactly as
|
differ, and the guards split them exactly as
|
||||||
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio
|
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s
|
||||||
rows record: leave a fragment **out of the deletion** and it survives on
|
trio rows record: leave a fragment **out of the deletion** and it survives
|
||||||
HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed)
|
on HEAD, where
|
||||||
already refuses the bare tree (`not consumed`) — this guard goes red too,
|
[changelog-armed](#changelog-armed--main-never-sits-disarmed) already
|
||||||
naming the entry the section lost. But **delete** a fragment while omitting
|
refuses the bare tree (`not consumed`) — this guard goes red too, naming
|
||||||
its entry from the stamp, or hand-edit one word of the assembled prose, and
|
the entry the section lost. But **delete** a fragment while omitting its
|
||||||
nothing on HEAD is out of place: armed is green, monotonic is green, and the
|
entry from the stamp, or hand-edit one word of the assembled prose, and
|
||||||
publisher would happily publish history that is not what the authors wrote.
|
nothing on HEAD is out of place: armed is green, monotonic is green, and
|
||||||
Only the merge-base replay catches those. The replay is what makes a
|
the publisher would happily publish history that is not what the authors
|
||||||
hand-run step safe. **This guard needs history** — same stance as the
|
wrote. Only the merge-base replay catches those. The replay is what
|
||||||
monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard
|
makes a hand-run step safe. **This guard needs history** — same stance as
|
||||||
failure, not a skip.
|
the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a
|
||||||
|
hard failure, not a skip.
|
||||||
|
|
||||||
### changelog-monotonic — shipped headings are append-only
|
### changelog-monotonic — shipped headings are append-only
|
||||||
|
|
||||||
**The rule**
|
**The rule**
|
||||||
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh)):
|
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)):
|
||||||
the set of `## X.Y.Z` headings on your branch must be a **superset** of the
|
the set of `## X.Y.Z` headings on your branch must be a **superset** of
|
||||||
set at the merge base, and no heading may appear twice on HEAD. The rule
|
the set at the merge base, and no heading may appear twice on HEAD. The
|
||||||
needs no tuning because release headings are append-only by doctrine: the
|
rule needs no tuning because release headings are append-only by doctrine:
|
||||||
ceremony adds one and nothing ever legitimately removes one — so superset
|
the ceremony adds one and nothing ever legitimately removes one — so
|
||||||
has no exception to carve. The ceremony's own stamp passes by construction:
|
superset has no exception to carve. The ceremony's own stamp passes by
|
||||||
the assembler writes a new `## X.Y.Z — DATE` heading and removes none.
|
construction: the assembler writes a new `## X.Y.Z — DATE` heading and
|
||||||
Fragment mode changes nothing here (#112 D10): fragments add no `## `
|
removes none. Fragment mode changes nothing here (#112 D10): fragments add
|
||||||
heading, and `Unreleased` was never in the guard's set — it is not a version
|
no `## ` heading, and `Unreleased` was never in the guard's set — it is
|
||||||
heading; it is
|
not a version heading; it is
|
||||||
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business —
|
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business —
|
||||||
which is why a repo's adoption PR can delete it and stay green.
|
which is why a repo's adoption PR can delete it and stay green.
|
||||||
|
|
||||||
**The incidents**: box#122 (caught in review of box#118) — an author adding
|
**The incidents**: box#122 (caught in review of box#118) — an author
|
||||||
an entry under `## Unreleased` **replaced** the heading below it instead of
|
adding an entry under `## Unreleased` **replaced** the heading below it
|
||||||
inserting above it; git merges that cleanly, and the shipped section's body
|
instead of inserting above it; git merges that cleanly, and the shipped
|
||||||
is silently absorbed into `## Unreleased`. And box#118 itself — a bad rebase
|
section's body is silently absorbed into `## Unreleased`. And box#118
|
||||||
*duplicated* a shipped heading, which containment is blind to, which is why
|
itself — a bad rebase *duplicated* a shipped heading, which containment is
|
||||||
uniqueness-on-HEAD is a separate assert.
|
blind to, which is why uniqueness-on-HEAD is a separate assert
|
||||||
|
([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)).
|
||||||
|
|
||||||
**Red means** a shipped section was deleted (put the heading back and insert
|
**Red means** a shipped section was deleted (put the heading back and
|
||||||
**above** it) or duplicated (collapse to one heading; the failure message
|
insert **above** it) or duplicated (collapse to one heading; the failure
|
||||||
walks through both fixes with the diff to run). **This guard needs
|
message walks through both fixes with the diff to run). **This guard needs
|
||||||
history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an
|
history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an
|
||||||
unresolvable base is a hard failure, not a skip — a guard that can quietly
|
unresolvable base is a hard failure, not a skip — a guard that can quietly
|
||||||
stop guarding is the failure shape this family of checks exists to refuse.
|
stop guarding is the failure shape this family of checks exists to refuse
|
||||||
|
([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)).
|
||||||
|
|
||||||
### drill-recorded — a release carries its evidence
|
### drill-recorded — a release carries its evidence
|
||||||
|
|
||||||
**The rule**
|
**The rule**
|
||||||
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh)),
|
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)),
|
||||||
keyed on the tree's version: a `-dev` tree passes with nothing to assert (a
|
keyed on the tree's version: a `-dev` tree passes with nothing to assert
|
||||||
development tree ships nothing); a bare tree — the ceremony PR and its merge
|
(a development tree ships nothing); a bare tree — the ceremony PR and its
|
||||||
— must carry `drills/<version>.md` with at least one non-whitespace
|
merge — must carry `drills/<version>.md` with at least one
|
||||||
character. One file per version, so `0.9.0.md` and `0.9.0-rc1.md` are simply
|
non-whitespace character. One file per version, so `0.9.0.md` and
|
||||||
different files and prefix confusion is unrepresentable (#1 constraint 7).
|
`0.9.0-rc1.md` are simply different files and prefix confusion is
|
||||||
|
unrepresentable (#1 constraint 7).
|
||||||
|
|
||||||
**The incident**: box's CONTRIBUTING said since box#96 that the release
|
**The incident**: box's CONTRIBUTING said since box#96 that the release
|
||||||
ritual must be run and recorded. No release ever did it — box#95, box#114
|
ritual must be run and recorded. No release ever did it — box#95, box#114
|
||||||
and box#148 all shipped as a version bump plus a changelog stamp, because
|
and box#148 all shipped as a version bump plus a changelog stamp, because
|
||||||
the gate was a sentence in a document and the only thing standing on it was
|
the gate was a sentence in a document and the only thing standing on it
|
||||||
a reviewer remembering to ask. The rule moved into CI, where it fires
|
was a reviewer remembering to ask. The rule moved into CI, where it fires
|
||||||
whether or not anyone is paying attention.
|
whether or not anyone is paying attention.
|
||||||
|
|
||||||
**Red means** the release is asserting a ritual it left no evidence of.
|
**Red means** the release is asserting a ritual it left no evidence of.
|
||||||
**The fix is to run the drill** and record it — or to waive it *in writing*
|
**The fix is to run the drill** and record it — or to waive it *in
|
||||||
at the same path: the guard demands a **record, not a passing result**
|
writing* at the same path: the guard demands a **record, not a passing
|
||||||
([below](#the-drill-doctrine)).
|
result** ([below](#the-drill-doctrine)).
|
||||||
|
|
||||||
## The drill doctrine
|
## The drill doctrine
|
||||||
|
|
||||||
**Evidence, not success.** The guard asserts a record exists — a failed
|
**Evidence, not success.** The guard asserts a record exists — a failed
|
||||||
drill honestly written down satisfies it, and so does a maintainer waiver
|
drill honestly written down satisfies it, and so does a maintainer waiver
|
||||||
that says plainly the drill was waived and why. What it refuses is silence:
|
that says plainly the drill was waived and why. What it refuses is
|
||||||
a skip must cost a deliberate, reviewable file in the diff, which is
|
silence: a skip must cost a deliberate, reviewable file in the diff,
|
||||||
precisely what box's three silent skips never produced. CI cannot run a
|
which is precisely what box's three silent skips never produced. CI
|
||||||
consumer's drill (box's wants real hardware and the better part of an hour);
|
cannot run a consumer's drill (box's wants real hardware and the better
|
||||||
it can only refuse a release that never ran one.
|
part of an hour); it can only refuse a release that never ran one.
|
||||||
|
|
||||||
**Each repo defines what its drill *means*** — the gate only reads the
|
**Each repo defines what its drill *means*** — the gate only reads the
|
||||||
record. box asserts the **isolation contract**; rig asserts **convergence**
|
record. box asserts the **isolation contract**; rig asserts
|
||||||
(a machine reaches its role, idempotently); cast asserts **promotion** (A→B
|
**convergence** (a machine reaches its role, idempotently); cast asserts
|
||||||
reproduces, the diff is idempotent); ceremony's own drill is a **door
|
**promotion** (A→B reproduces, the diff is idempotent); ceremony's own
|
||||||
rehearsal** — both doors exercised end-to-end on a disposable repo, written
|
drill is a **door rehearsal** — both doors exercised end-to-end on a
|
||||||
out step by step in [drills/README.md](drills/README.md), with the records
|
disposable repo (#11 names the six probes); incubator's is TBD in
|
||||||
themselves in [drills/](drills/); incubator asserts the **staging verify** —
|
heavy-duty/incubator. Each repo states its meaning in its own
|
||||||
the canonical candidate deployed, its smoke probe run *inside* the staging
|
`drills/README.md`. Three different exercises sharing a substrate is why
|
||||||
container on the deployed environment's credentials, the record pinning the
|
the records are per-repo — they are not phases of one script.
|
||||||
commit SHA and image digest that were exercised
|
|
||||||
([heavy-duty/incubator `drills/README.md`](https://github.com/heavy-duty/incubator/blob/main/drills/README.md)).
|
|
||||||
Each repo states its meaning in its own `drills/README.md`. Five different
|
|
||||||
exercises sharing a substrate is why the records are per-repo — they are not
|
|
||||||
phases of one script.
|
|
||||||
|
|
||||||
**Drills exercise candidate refs, not released artifacts.** A ref is a
|
**Drills exercise candidate refs, not released artifacts.** A ref is a
|
||||||
static identifier that exists as soon as the release branch does, so no repo
|
static identifier that exists as soon as the release branch does, so no
|
||||||
has to be released — or drilled — before another can be drilled: what looks
|
repo has to be released — or drilled — before another can be drilled:
|
||||||
like a box↔rig recursion at runtime dissolves into two independent tests
|
what looks like a box↔rig recursion at runtime dissolves into two
|
||||||
against one fixed pair of refs. And drilling the candidate *is* drilling the
|
independent tests against one fixed pair of refs. And drilling the
|
||||||
release: a ceremony PR's diff is the stamps and nothing else, so no
|
candidate *is* drilling the release: a ceremony PR's diff is the stamps
|
||||||
executable byte differs between the tree that was drilled and the tree that
|
and nothing else, so no executable byte differs between the tree that was
|
||||||
ships.
|
drilled and the tree that ships.
|
||||||
|
|
||||||
**A cross-repo release set shares one run ID.** Each repo records its own
|
**A cross-repo release set shares one run ID.** Each repo records its own
|
||||||
legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling SHAs,
|
legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling
|
||||||
so the records reconcile afterwards — but the guard only ever reads the repo
|
SHAs, so the records reconcile afterwards — but the guard only ever reads
|
||||||
it runs in. If a defect shows up only in the combination: patch, re-drill,
|
the repo it runs in. If a defect shows up only in the combination: patch,
|
||||||
re-record. The set converges; it is not required to be right in one pass.
|
re-drill, re-record. The set converges; it is not required to be right in
|
||||||
|
one pass.
|
||||||
|
|
||||||
## Troubleshooting red main
|
## Troubleshooting red main
|
||||||
|
|
||||||
Every refusal the release flow can emit, verbatim, with cause and remedy.
|
Every refusal the release flow can emit, verbatim, with cause and remedy.
|
||||||
The catalog is generated from the sources, not paraphrased — regenerate it
|
The catalog is generated from the sources, not paraphrased — regenerate
|
||||||
with:
|
it with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
grep -n -A2 'refuse \|>&2' \
|
grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml
|
||||||
lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`$VER`-style variables appear as the run interpolates them. One refusal is
|
`$VER`-style variables appear as the run interpolates them.
|
||||||
outside that command by construction: `version_read: $path: no version field`
|
|
||||||
is a `console.error` inside the node one-liner at
|
|
||||||
[lib/version.sh#L55](lib/version.sh#L55) — no `>&2`, no `refuse `, so the grep
|
|
||||||
cannot see it. It is quoted below as it reaches the log at run time, which is
|
|
||||||
the convention this catalog is written to.
|
|
||||||
|
|
||||||
### The decision refused ([lib/decide.sh](lib/decide.sh))
|
### The decision refused ([lib/decide.sh](lib/decide.sh))
|
||||||
|
|
||||||
|
|
@ -441,11 +347,12 @@ or — if the tree is genuinely the release — publish by the tag door.
|
||||||
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
||||||
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
||||||
|
|
||||||
The fact-gathering guards ([L92–L105](lib/decide.sh#L92-L105),
|
The fact-gathering guards
|
||||||
[L135](lib/decide.sh#L135), [L151](lib/decide.sh#L151)): a missing fact must
|
([L92–L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135),
|
||||||
never fall through to "no". These indicate a bug upstream in
|
[L151](lib/decide.sh#L151)): a missing fact must never fall through to
|
||||||
[lib/facts.sh](lib/facts.sh) or the workflow plumbing, not an operator
|
"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the
|
||||||
mistake — read the run's `facts:` stderr line and file what you find.
|
workflow plumbing, not an operator mistake — read the run's `facts:`
|
||||||
|
stderr line and file what you find.
|
||||||
|
|
||||||
### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh))
|
### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh))
|
||||||
|
|
||||||
|
|
@ -460,162 +367,89 @@ mistake — read the run's `facts:` stderr line and file what you find.
|
||||||
> version_read: node is required for version-source: package-json
|
> version_read: node is required for version-source: package-json
|
||||||
|
|
||||||
[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is
|
[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is
|
||||||
missing, empty, or unreadable. A wrong release is worse than a missing one,
|
missing, empty, or unreadable. A wrong release is worse than a missing
|
||||||
so an unreadable state is never an empty print — restore the `VERSION` file
|
one, so an unreadable state is never an empty print — restore the
|
||||||
(or `package.json` version field) on main.
|
`VERSION` file (or `package.json` version field) on main.
|
||||||
|
|
||||||
> version_read: unknown backend: $backend
|
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300))
|
||||||
|
|
||||||
[L62](lib/version.sh#L62): not an operator mistake and not reachable through
|
|
||||||
the release flow — [lib/facts.sh](lib/facts.sh#L33-L40) rejects a bad
|
|
||||||
`VERSION_SOURCE` with the message above before `version_read` is ever called,
|
|
||||||
so this line can only appear when some *other* caller invokes `version_read`
|
|
||||||
directly with a backend that is neither `file` nor `package-json`. Fix that
|
|
||||||
caller.
|
|
||||||
|
|
||||||
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310))
|
|
||||||
|
|
||||||
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
|
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
|
||||||
|
|
||||||
[L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged
|
[L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged
|
||||||
without its stamp (a state the
|
without its stamp (a state the
|
||||||
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses on
|
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses
|
||||||
the PR — red main here means it was overridden). Stamp the section on main,
|
on the PR — red main here means it was overridden). Stamp the section on
|
||||||
then publish by the tag door.
|
main, then publish by the tag door.
|
||||||
|
|
||||||
> release '$VER' already exists — this release already happened; refusing to re-release, creating nothing.
|
> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
|
||||||
> tag '$VER' already exists at <tag sha> but this run would tag <MERGE_SHA> — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit.
|
> release '$VER' already exists — refusing to re-release, creating nothing.
|
||||||
> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run.
|
|
||||||
|
|
||||||
[L208–L239](.github/workflows/release.yml#L208-L239), the merge-door
|
[L207–L222](.github/workflows/release.yml#L207-L222), the nothing-exists
|
||||||
preflight — the published-release refusal prevents clobbering, the
|
assert — what makes a re-run of a completed ceremony refuse instead of
|
||||||
different-commit refusal diagnoses a racing or manual tag with both SHAs, and
|
clobber, and what catches a manual tag racing the merge. If the release
|
||||||
the notice resumes this door after its tag succeeded but the artifact hook or
|
truly exists, there is nothing to do: this red is the system declining to
|
||||||
publish failed. Re-run the merge-door job first. If that run is no longer
|
do the thing twice. If the tag exists but the release does not (a manual
|
||||||
reachable or the tree itself needs repair, use the tag-door fallback: delete
|
tag won the race, or
|
||||||
and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
|
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by
|
||||||
|
the tag door: delete and re-push the tag, or `gh release create` by hand
|
||||||
|
from a fixed tree.
|
||||||
|
|
||||||
> direct push refused (branch protection?) — opening the bump PR instead
|
> direct push refused (branch protection?) — opening the bump PR instead
|
||||||
|
|
||||||
[L302–L310](.github/workflows/release.yml#L302-L310) — loud, but not a
|
[L292–L300](.github/workflows/release.yml#L292-L300) — loud, but not a
|
||||||
refusal: the post-release `-dev` bump could not push directly, so the run
|
refusal: the post-release `-dev` bump could not push directly, so the run
|
||||||
opened a `release`-labeled bump PR itself. Your move: merge it promptly —
|
opened a `release`-labeled bump PR itself. Your move: merge it promptly —
|
||||||
until it lands, main is sitting bare, where a dev install impersonates the
|
until it lands, main is sitting bare, where a dev install
|
||||||
release and the
|
[impersonates the release](.github/workflows/release.yml#L291) and the
|
||||||
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
|
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
|
||||||
open.
|
open.
|
||||||
|
|
||||||
### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
|
### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369))
|
||||||
|
|
||||||
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
|
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
|
||||||
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
|
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
|
||||||
|
|
||||||
[L356–L359](.github/workflows/release.yml#L356-L359). The message is the
|
[L333–L337](.github/workflows/release.yml#L333-L337). The message is the
|
||||||
remedy.
|
remedy.
|
||||||
|
|
||||||
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
|
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
|
||||||
|
|
||||||
[L368–L374](.github/workflows/release.yml#L368-L374). The tagged tree was
|
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
|
||||||
never stamped. Assemble the section
|
never stamped. Assemble the section
|
||||||
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
|
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)),
|
||||||
delete and re-push the tag.
|
then delete and re-push the tag.
|
||||||
|
|
||||||
> release '$VER' already exists — refusing to re-release, creating nothing.
|
|
||||||
|
|
||||||
[L375–L390](.github/workflows/release.yml#L375-L390). A published release is
|
|
||||||
never replaced by the fallback. If it is correct, there is nothing to do; if
|
|
||||||
it is wrong, correct that published artifact deliberately before retrying.
|
|
||||||
|
|
||||||
### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310))
|
|
||||||
|
|
||||||
The bump belongs to the merge door alone — the tag door deliberately does not
|
|
||||||
rewrite main ([L325–L329](.github/workflows/release.yml#L325-L329)) — and it
|
|
||||||
runs *after* the tag, the notes and the publish. So a refusal here leaves a
|
|
||||||
real release standing behind a main that never re-armed — the release exists,
|
|
||||||
and main is left *armed to impersonate* it, still reading the version it just
|
|
||||||
shipped ([L275](.github/workflows/release.yml#L275)). That is the one failure
|
|
||||||
in this catalog whose remedy is a manual bump, not a re-run.
|
|
||||||
|
|
||||||
> version_next_dev: refusing '$ver' — expected bare X.Y.Z
|
|
||||||
|
|
||||||
[L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`.
|
|
||||||
Two senses of *bare* meet here, and the gap between them is the **rc release
|
|
||||||
path** — the way this refusal is actually reached, and designed behaviour
|
|
||||||
rather than a decide bug. decide calls a version bare when it is not `-dev`
|
|
||||||
([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing
|
|
||||||
else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony
|
|
||||||
is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)).
|
|
||||||
`version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the
|
|
||||||
two, and nothing filters it out on the way: the step's only gate is
|
|
||||||
`ceremony == 'yes'` and its `VER` is the tree's version verbatim. So an rc
|
|
||||||
ceremony tags, writes the notes, publishes — and *then* the re-arm refuses.
|
|
||||||
That is the machine correctly declining to guess rather than a bug: an rc's
|
|
||||||
next version "is a human decision, not arithmetic"
|
|
||||||
([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by
|
|
||||||
hand to it. A `-dev` version reaching this line is the same refusal's other
|
|
||||||
half, and *that* half is unreachable as the doors stand — rows 1–2 send `-dev`
|
|
||||||
to a no-op, and the tag door never bumps. A malformed version is not: nothing
|
|
||||||
upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only
|
|
||||||
that a version is present and non-empty), so `banana` rides row 6 exactly as
|
|
||||||
an rc does, and the same manual bump is the remedy. One note on work that has
|
|
||||||
not landed: #317 would make rc cuts native and their re-arm deterministic, and
|
|
||||||
if it lands only the malformed half still reaches this refusal.
|
|
||||||
|
|
||||||
> version_write: npm is required for version-source: package-json
|
|
||||||
|
|
||||||
[L106](lib/version.sh#L106): the package-json backend needs npm to write —
|
|
||||||
`npm pkg set version=` plus a lockfile-only `npm install`
|
|
||||||
([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag
|
|
||||||
— and the runner has none. The read path fails the same way one step earlier
|
|
||||||
(`node is required…`, above), so a run reaching *this* message got past the
|
|
||||||
read — set up node/npm in the caller.
|
|
||||||
|
|
||||||
> version_write: unknown backend: $backend
|
|
||||||
|
|
||||||
[L118](lib/version.sh#L118): the write-side twin of `version_read: unknown
|
|
||||||
backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated
|
|
||||||
before either was called. Fix the caller.
|
|
||||||
|
|
||||||
In every case the remedy has the same shape — bump `VERSION` (or the
|
|
||||||
`package.json` version field) by hand and push: `X.Y.(Z+1)-dev` where the
|
|
||||||
shipped version was bare, and where it was an rc, whatever you have decided
|
|
||||||
comes next. Note that a *push* refusal is not one of these — branch
|
|
||||||
protection is expected, and the step opens the bump PR itself rather than
|
|
||||||
failing ([L302–L310](.github/workflows/release.yml#L302-L310)).
|
|
||||||
|
|
||||||
### Red main that is not the release workflow
|
### Red main that is not the release workflow
|
||||||
|
|
||||||
Consumer CI runs its guard steps on pushes to main too (this repo's
|
Consumer CI runs its guard steps on pushes to main too (this repo's
|
||||||
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
|
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
|
||||||
operator will actually meet on main is **changelog-armed after a re-arm was
|
operator will actually meet on main is **changelog-armed after a re-arm
|
||||||
forgotten — legacy mode only**: the ceremony stamped without putting
|
was forgotten — legacy mode only**: the ceremony stamped without putting
|
||||||
`## Unreleased` back, the release's own `-dev` bump landed, and the guard now
|
`## Unreleased` back, the release's own `-dev` bump landed, and the guard
|
||||||
says (first line):
|
now says (first line):
|
||||||
|
|
||||||
> changelog-armed: the version is '$ver' (a development tree) but the top
|
> changelog-armed: the version is '$ver' (a development tree) but the top
|
||||||
> section of $changelog is: …
|
> section of $changelog is: …
|
||||||
|
|
||||||
The fix is a one-line PR: add an empty `## Unreleased` above the stamped
|
The fix is a one-line PR: add an empty `## Unreleased` above the stamped
|
||||||
section. The full message carries the same instruction. Fragment mode has no
|
section. The full message carries the same instruction. Fragment mode has
|
||||||
re-arm to forget, so it has no equivalent red on main — its refusals (a
|
no re-arm to forget, so it has no equivalent red on main — its refusals
|
||||||
missing marker, a surviving `## Unreleased`, a malformed or unconsumed
|
(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed
|
||||||
fragment) all fire on the PR that caused them, where the author is still
|
fragment) all fire on the PR that caused them, where the author is still
|
||||||
holding it.
|
holding it.
|
||||||
|
|
||||||
## Design lineage
|
## Design lineage
|
||||||
|
|
||||||
The ceremony converged across box#83 → box#96, rig#32 → rig#47 and cast#96 →
|
The ceremony converged across box#83 → box#96, rig#32 → rig#47, and
|
||||||
cast#111; this repo is those three implementations folded into one, and the
|
cast#96 → cast#111; this repo is those three implementations folded into
|
||||||
drift that motivated it is measured in
|
one (the drift that motivated it is measured in
|
||||||
[#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the
|
[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing
|
||||||
load-bearing constraints — each bought with an incident, none of them safe
|
constraints — each bought with an incident, none of them safe to
|
||||||
to "simplify" away. The label machine's own record is #10, #11 and #130; the
|
"simplify" away — are listed in
|
||||||
issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and
|
[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with
|
||||||
#116; the sweep/trigger split is #209.
|
their war stories, in the headers of the scripts they bind:
|
||||||
|
[release.yml](.github/workflows/release.yml#L1-L109),
|
||||||
The narrative lives in those issues, by design: the war stories are carried
|
[lib/decide.sh](lib/decide.sh#L1-L74),
|
||||||
in the headers of the scripts they bind —
|
[lib/facts.sh](lib/facts.sh#L1-L24), and the four
|
||||||
[release.yml](.github/workflows/release.yml),
|
[guard scripts](actions/). The comments are the documentation of record;
|
||||||
[lib/decide.sh](lib/decide.sh), [lib/facts.sh](lib/facts.sh) and the
|
this README is their operator-facing cut.
|
||||||
[guard scripts](actions/) — and those comments are the documentation of
|
|
||||||
record. This README is their operator-facing cut.
|
|
||||||
|
|
|
||||||
219
RELEASES.md
219
RELEASES.md
|
|
@ -1,219 +0,0 @@
|
||||||
# Release management
|
|
||||||
|
|
||||||
This file describes the release-management pattern available to governed
|
|
||||||
repositories. Adoption is per repository and operator-ruled: a repository
|
|
||||||
without version epics is not out of compliance. A repo-local roadmap is the
|
|
||||||
map; each epic remains the source of truth for its own release. Where an older
|
|
||||||
repo-local description differs from this file, this file governs.
|
|
||||||
|
|
||||||
## The ladder
|
|
||||||
|
|
||||||
Represent each planned release with one version epic. The epic is the working
|
|
||||||
surface for that release: it states the goal, names the members, and records
|
|
||||||
the ordered waves as checklists. Keep the machine-readable progress checklist
|
|
||||||
under a heading matching `## Task list`, case-insensitively; the issue-flow
|
|
||||||
sweep reads task rows there until the next heading when it decides whether to
|
|
||||||
nudge triage about a completed epic. Other member or wave headings are not
|
|
||||||
completion inputs.
|
|
||||||
|
|
||||||
Keep a short repo-local roadmap beside the epics. The roadmap shows the whole
|
|
||||||
ladder and points to each working surface; it does not duplicate the live
|
|
||||||
member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338)
|
|
||||||
maps the ladder whose `0.1.2` working surface moved from the crufty ledger
|
|
||||||
[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
|
||||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346).
|
|
||||||
|
|
||||||
## Gates
|
|
||||||
|
|
||||||
Each version epic declares `Blocked by <predecessor>`. Special ordering — a
|
|
||||||
double gate or an out-of-chain gate — is written explicitly on that epic;
|
|
||||||
there is no hidden global schedule. The epic carries `epic` and the
|
|
||||||
repository's release label, with no queue label. Its `Blocked by` line is a
|
|
||||||
declaration a human reads: shipping closes the predecessor, then triage opens
|
|
||||||
the next window by hand as the first step of release-init. The issue-flow
|
|
||||||
sweep does not promote version epics; automating that gate would require a
|
|
||||||
separately specified change to its queue-category model.
|
|
||||||
|
|
||||||
The gate orders windows, not their contents. Members enter a release only by
|
|
||||||
decision during release-init. The double gate on
|
|
||||||
[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the
|
|
||||||
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
|
||||||
are worked examples of exceptions declared where they apply.
|
|
||||||
|
|
||||||
## The membership record
|
|
||||||
|
|
||||||
A release issue's `Blocked by` line answers the predecessor gate above and
|
|
||||||
nothing else. Which issues are *in* the release is a separate record on the
|
|
||||||
same issue, and the sweep reads it by heading (#343):
|
|
||||||
|
|
||||||
- the heading is literally `## Members`, matched case-insensitively, tolerant
|
|
||||||
of any run of whitespace between the `##` and the word and of trailing
|
|
||||||
whitespace after it, and the record runs to the next heading — the same
|
|
||||||
shape `## Task list` already has;
|
|
||||||
- one member per list row, under any Markdown list marker and only those:
|
|
||||||
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
|
|
||||||
because a row is whatever a reader sees as one — and a tenth digit opens
|
|
||||||
nothing, CommonMark's ordered marker being at most nine digits, so
|
|
||||||
`1234567890. #412` is narration and enrols no member. Indentation is bounded
|
|
||||||
the same way: up to three spaces still open a row, four or more open nothing,
|
|
||||||
a leading tab counting as four. The record is **flat** — one member per
|
|
||||||
top-level row — and past that bound a line is not one: standing alone it is
|
|
||||||
an indented code block, and under a row it is a sub-bullet annotating that
|
|
||||||
member, and neither is a member itself. Below the bound it enrols, an
|
|
||||||
indented row being the same bytes as a top-level one. The member is the
|
|
||||||
row's first token after the list marker and an optional checkbox, and it is
|
|
||||||
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
|
|
||||||
Everything after that token is prose and contributes nothing, so a row is
|
|
||||||
free to cite the PR that closed it, a sibling repository, or an issue it
|
|
||||||
names as explicitly *not* a member;
|
|
||||||
- a row whose first token is anything else — a qualified `repo#N`, a number
|
|
||||||
with punctuation attached, or ordinary prose — contributes no member. The
|
|
||||||
parse stays silent rather than guessing;
|
|
||||||
- a qualified reference is never a member: a window is one repository's DAG,
|
|
||||||
decided against one board read;
|
|
||||||
- a row naming the release issue itself contributes no member. The sink is
|
|
||||||
never one of its own members;
|
|
||||||
- **there is no fallback to the gate.** A release issue with no members
|
|
||||||
section enumerates no membership, is not a standing window, and draws no
|
|
||||||
window flag. A repository whose epics predate this record gets silence,
|
|
||||||
never a false flag, until its next release-init writes one.
|
|
||||||
|
|
||||||
Why a heading and not a marker phrase: the `Blocked by` parse unions every
|
|
||||||
occurrence of its marker and runs each clause to a sentence terminator, which
|
|
||||||
is the right error direction for a `blocked` issue and the wrong one for a
|
|
||||||
release body that is mostly narration *about* its members. Why the first token
|
|
||||||
and not every reference in the row: a real member row cites merged PRs, other
|
|
||||||
repositories and explicit non-members, and reading the whole row enrols all of
|
|
||||||
them.
|
|
||||||
|
|
||||||
The cost is named rather than hidden: a version epic maintains two lists — the
|
|
||||||
`## Members` record and the `## Task list` progress view — and triage writes
|
|
||||||
both in the same flip. The purchase is that the progress view stays a progress
|
|
||||||
view, prose-rich and free to carry several issues in one row or to omit a
|
|
||||||
member that is not in the build queue, while membership is a machine record
|
|
||||||
with exactly one shape.
|
|
||||||
|
|
||||||
## Release-init
|
|
||||||
|
|
||||||
The predecessor closing and clearing the next epic's declared gate is the
|
|
||||||
trigger, and today triage must notice it and open that window by hand.
|
|
||||||
[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253)
|
|
||||||
tracks the not-yet-shipped sweep announcement of that duty; do not treat the
|
|
||||||
announcement as present until the consumer's pin carries it. Triage runs five
|
|
||||||
steps:
|
|
||||||
|
|
||||||
1. Mint the epic's “to mint when this arc opens” list together with findings,
|
|
||||||
deferred work, and discussion outcomes accumulated since the epic was
|
|
||||||
written. Each member initially declares `Blocked by <the epic>`.
|
|
||||||
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
|
|
||||||
3. Write the waves into the epic body as checklists in claim order, with a
|
|
||||||
separate verification lane and the progress view under `## Task list`, and
|
|
||||||
write the window's membership under `## Members` — release-init is where
|
|
||||||
that record is first written, and until it exists no window stands.
|
|
||||||
4. Ask the operator to bless the order, then have triage open the first wave
|
|
||||||
by applying the flip mechanics below. The operator's blessing is the one
|
|
||||||
step this chain never automates.
|
|
||||||
5. Ship through the repository's cut process, close the epic, and treat that
|
|
||||||
close as the trigger for the next window.
|
|
||||||
|
|
||||||
heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges
|
|
||||||
and shared-file contention visible before builders entered the queue. If init
|
|
||||||
finds no work worth minting, the operator either folds the empty window into a
|
|
||||||
later release or skips the version, recording that ruling on the epic before
|
|
||||||
closing it unshipped.
|
|
||||||
|
|
||||||
## One primary window, declared parallel tracks
|
|
||||||
|
|
||||||
Run one primary release window by default. A cut takes whatever has landed, so
|
|
||||||
interleaving unrelated windows blurs both the release story and the evidence
|
|
||||||
behind it. Gates open windows; they do not silently admit members, so builders
|
|
||||||
still see one deliberately ordered queue.
|
|
||||||
|
|
||||||
While a window stands — an open release-labeled issue whose membership record
|
|
||||||
holds at least one open member — its members form a DAG whose sink is the
|
|
||||||
release issue. Every member reaches that sink. Members declare only their
|
|
||||||
immediate predecessors; ordering edges live on members, while the sink records
|
|
||||||
membership only, in the record above and nowhere else; and the `ready` set is
|
|
||||||
exactly the graph's current sources. Every close
|
|
||||||
releases exactly its declared successors, and that whole set is concurrently
|
|
||||||
claimable: a member may have multiple successors, while the collision rule
|
|
||||||
already orders any that share a deliverable. Insertion re-points downstream
|
|
||||||
edges rather than merely appending membership at the sink. It follows that
|
|
||||||
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
|
|
||||||
because neither is claimable (#292).
|
|
||||||
|
|
||||||
A member that lands `post-merge` releases nothing: that exemption is about
|
|
||||||
claimability, while a `post-merge` issue is still open and an open predecessor
|
|
||||||
holds its successors, so every successor declaring on it stays held and the
|
|
||||||
window stops advancing along that edge (#329).
|
|
||||||
|
|
||||||
**When a member reaches `post-merge` and any open declaration names it, triage
|
|
||||||
splits the remainder**: mint a fresh issue carrying the outstanding criteria
|
|
||||||
verbatim, naming its owner and its wake condition and citing the original, then
|
|
||||||
close the original on what it delivered. Triage owns this because only triage
|
|
||||||
mints work issues and `post-merge` is its completion queue (#329).
|
|
||||||
|
|
||||||
**The release edge is the original's close, never the remainder's.** Each
|
|
||||||
successor's declaration names the original's number, so closing the new issue
|
|
||||||
releases nothing (#329).
|
|
||||||
|
|
||||||
**Split only when an open declaration names the issue.** The trigger is a check
|
|
||||||
rather than a judgement — run the blocker parse over every open `blocked` body
|
|
||||||
and see whether this number appears — because an issue that strands nothing is
|
|
||||||
`post-merge` working as intended (#329).
|
|
||||||
|
|
||||||
**Never close work out from under a builder.** Where the original is assigned,
|
|
||||||
`claimed`, or carrying an open PR, amend its body to hand the outstanding
|
|
||||||
criteria to the new issue and let its holder close it, so the release edge above
|
|
||||||
is reached without taking the work from them (#329).
|
|
||||||
|
|
||||||
**Do not instead teach the blocker parse that `post-merge` counts as landed.**
|
|
||||||
That promotes a successor while its predecessor still owes acceptance criteria,
|
|
||||||
inverts a parser whose deliberate error direction is to hold or flag a reference
|
|
||||||
it cannot read rather than release it, and needs label data a reference-state
|
|
||||||
lookup does not carry (#329).
|
|
||||||
|
|
||||||
The operator may declare a parallel track at init when its footprint is
|
|
||||||
disjoint from the primary window: another repository, another artifact, or
|
|
||||||
provably non-overlapping clusters. The declaration names the boundary and any
|
|
||||||
bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
|
||||||
is the worked example: its app and artifact form a parallel track while its
|
|
||||||
small crew-side bridge remains in the primary window.
|
|
||||||
|
|
||||||
## Flip mechanics
|
|
||||||
|
|
||||||
To admit a member, delete or rewrite its literal, parseable
|
|
||||||
`Blocked by <the epic>` declaration and swap `blocked` to `ready` in the same
|
|
||||||
edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads
|
|
||||||
the raw marker text and still returns the reference. Never preserve history by
|
|
||||||
negating the marker phrase — the parser unions declarations even when prose
|
|
||||||
says they no longer apply. Preserve the history only after rewriting the
|
|
||||||
marker into non-parseable prose, then verify that the parser returns an empty
|
|
||||||
set for the release gate.
|
|
||||||
|
|
||||||
**The same flip adds the member's row to the release issue's membership
|
|
||||||
record.** That write is not bookkeeping to catch up on later: the record is
|
|
||||||
the only thing that makes the window stand, so a member flipped `ready`
|
|
||||||
without a row is, to the sweep, an unblocked non-member — the exact state the
|
|
||||||
window flag exists to report. Verify the flip by reading the record back and
|
|
||||||
finding the new member's row in it (#343).
|
|
||||||
|
|
||||||
Release membership is a decision, never a sweep default. Triage performs each
|
|
||||||
flip only after the operator blesses the wave; the issue-flow sweep may resolve
|
|
||||||
ordinary issue dependencies, but it does not choose a release's contents.
|
|
||||||
heavy-duty/crew#346 records the member-by-member flip that opened its first
|
|
||||||
wave.
|
|
||||||
|
|
||||||
## The ledger pattern
|
|
||||||
|
|
||||||
When a release epic has become too crufty to remain a clear working surface,
|
|
||||||
create a replacement and treat the old epic as a ledger. Do not close the old
|
|
||||||
epic until every live member declaration points at the replacement and the
|
|
||||||
blocker parser verifies the new set. Closing early can release every member
|
|
||||||
that still names the old issue.
|
|
||||||
|
|
||||||
The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
|
||||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346)
|
|
||||||
transition is the worked example: all member declarations were re-pointed and
|
|
||||||
parse-verified before #162 closed; #162 remains the historical record while
|
|
||||||
#346 is the release's working surface.
|
|
||||||
33
REVIEWER.md
33
REVIEWER.md
|
|
@ -34,12 +34,7 @@ In order of authority:
|
||||||
not a defect: the issue directs it, triage owns that close, and a
|
not a defect: the issue directs it, triage owns that close, and a
|
||||||
request-changes on the "missing" keyword enforces the bug the shape
|
request-changes on the "missing" keyword enforces the bug the shape
|
||||||
exists to fix — `Closes #137` closed its issue with a post-merge
|
exists to fix — `Closes #137` closed its issue with a post-merge
|
||||||
criterion unmet (#151). For a `Refs #N` body, also verify that no closing
|
criterion unmet (#151). Check every
|
||||||
keyword immediately precedes `#N` anywhere in the body, even in prose
|
|
||||||
explaining the hand close or inside a code span: GitHub used those exact
|
|
||||||
shapes to close #209, #212 and #199 (#200, #218). The safe forms put the
|
|
||||||
number first (`#N is closed by hand`) or omit it (`triage closes the issue
|
|
||||||
by hand`). Check every
|
|
||||||
criterion; a PR that ships less than the issue says is a request-changes
|
criterion; a PR that ships less than the issue says is a request-changes
|
||||||
even if the code is beautiful.
|
even if the code is beautiful.
|
||||||
2. **The repo's load-bearing constraints** — the rules bought with
|
2. **The repo's load-bearing constraints** — the rules bought with
|
||||||
|
|
@ -54,19 +49,6 @@ In order of authority:
|
||||||
`0.1.0`'s `load_config` rejected `triage-actors=...` with
|
`0.1.0`'s `load_config` rejected `triage-actors=...` with
|
||||||
`malformed label row` and `exit=1`. CI green on a conversion PR proves
|
`malformed label row` and `exit=1`. CI green on a conversion PR proves
|
||||||
nothing about the new config: the base branch's workflow is what ran.
|
nothing about the new config: the base branch's workflow is what ran.
|
||||||
- **Third-party actions never hold a write-capable token by default.** In
|
|
||||||
any job whose token is write-capable (`packages: write`,
|
|
||||||
`contents: write`, `id-token: write`, or one carrying deploy secrets),
|
|
||||||
the default is a repo-owned script a test can drive. A third-party
|
|
||||||
action may hold that token only if it comes from an **established
|
|
||||||
publisher** — a real organization with maintenance history and more
|
|
||||||
than one maintainer, not a memberless shell or a lone account shipping
|
|
||||||
an unauditable `dist/` blob — and is **pinned by full commit SHA**. An
|
|
||||||
action matching the incubator red-flag profile never holds a write
|
|
||||||
token, however well it works. Read-only jobs: ordinary dependency
|
|
||||||
judgement, SHA-pinning still required. This is bot-run infrastructure —
|
|
||||||
no human watches runtime logs, so a compromised action's window is
|
|
||||||
unbounded (incubator#53/#54; #216).
|
|
||||||
3. **The code itself** — correctness first, then tests (does the test plan's
|
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||||
floor exist? do the failure cases actually fail?), then conventions.
|
floor exist? do the failure cases actually fail?), then conventions.
|
||||||
Changelog line present for behavior changes; comments carry why, not
|
Changelog line present for behavior changes; comments carry why, not
|
||||||
|
|
@ -83,9 +65,7 @@ saw Y" outranks one that says "this looks like it might".
|
||||||
wait for the repo to appear on a list: review is reversible
|
wait for the repo to appear on a list: review is reversible
|
||||||
read-plus-comment work, and the requester already decided it should happen.
|
read-plus-comment work, and the requester already decided it should happen.
|
||||||
- **A request is authorization, not panel membership.** Convergence is
|
- **A request is authorization, not panel membership.** Convergence is
|
||||||
measured against the target repo's `panel[<author>]=` line if its
|
measured against the target repo's `panel=` roster minus the author. If you
|
||||||
`labels.conf` defines one for the PR author, else its `panel=` line; minus
|
|
||||||
the author in either case (#224). If you
|
|
||||||
are requested off-panel, post the verdict anyway and say in its body that
|
are requested off-panel, post the verdict anyway and say in its body that
|
||||||
it is advisory; neither your silence nor your request-changes is a gate the
|
it is advisory; neither your silence nor your request-changes is a gate the
|
||||||
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
|
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
|
||||||
|
|
@ -123,9 +103,9 @@ saw Y" outranks one that says "this looks like it might".
|
||||||
## What you do not do
|
## What you do not do
|
||||||
|
|
||||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||||
above it, in a proposal where humans had their say. If you think the
|
above it, in a discussion where humans had their say. If you think the
|
||||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||||
proposal, while still reviewing the implementation against the spec as
|
discussion, while still reviewing the implementation against the spec as
|
||||||
written. Spec changes go through triage, not through a review round.
|
written. Spec changes go through triage, not through a review round.
|
||||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||||
human; only humans merge.
|
human; only humans merge.
|
||||||
|
|
@ -141,11 +121,6 @@ saw Y" outranks one that says "this looks like it might".
|
||||||
- The builder answers rounds whole and re-requests you; until re-requested,
|
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||||
the ball is not yours (`state:addressing` is the builder working — pile-on
|
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||||
reviews mid-address just churn the target).
|
reviews mid-address just churn the target).
|
||||||
- A **draft carrying `state:addressing` is a fix round in progress**, not
|
|
||||||
abandonment: an engine may convert a PR back to draft at round close so the
|
|
||||||
builder's mid-round saves stop firing CI, and the flip back to ready is the
|
|
||||||
builder's own act announcing the round is answered
|
|
||||||
([BUILDER.md](BUILDER.md#the-review-round)).
|
|
||||||
- Convergence = every panel verdict approves the current head, no
|
- Convergence = every panel verdict approves the current head, no
|
||||||
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||||
the panel's job is done.
|
the panel's job is done.
|
||||||
|
|
|
||||||
114
TRIAGE.md
114
TRIAGE.md
|
|
@ -1,28 +1,32 @@
|
||||||
# TRIAGE.md — the triage role
|
# TRIAGE.md — the triage role
|
||||||
|
|
||||||
You are the only door work issues come through. Humans and agents file
|
You are the only door issues come through. Humans and agents open
|
||||||
**proposals**; you decide what becomes work and set the quality builders and
|
**discussions**; you decide what becomes work. The quality of every
|
||||||
reviewers receive.
|
downstream stage — a builder succeeding without asking, a reviewer having a
|
||||||
|
spec to review against — is set here, by you, and nowhere else.
|
||||||
|
|
||||||
## Why this door exists
|
## Why this door exists
|
||||||
|
|
||||||
Proposals may be ambiguous; work issues may not: a builder must be able to
|
Discussions are allowed to be ambiguous; issues are not. An issue is a work
|
||||||
execute one **without asking anything**. One accountable role keeps builders
|
order a builder must be able to execute **without asking anyone anything**.
|
||||||
from guessing.
|
Keeping one accountable role between the two is what keeps the bar from
|
||||||
|
eroding — the moment anyone can mint an issue, the backlog fills with
|
||||||
|
"improve X" entries nobody can build, and builders start guessing. Guessing
|
||||||
|
is the failure this whole flow exists to prevent.
|
||||||
|
|
||||||
## Your inputs
|
## Your inputs
|
||||||
|
|
||||||
- **Every open proposal** in the repo you serve.
|
- **Every open discussion** in the repo you serve.
|
||||||
- **Stray issues** — anything filed outside the proposal form by a non-triage
|
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||||
actor. Label it
|
|
||||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||||
substance into a proposal and close it, saying why. Route the work
|
substance back into a discussion and close it, saying why. Do not shame the
|
||||||
without shaming the filer.
|
filer; do route the work correctly.
|
||||||
|
|
||||||
## For each proposal, converge on exactly one outcome
|
## For each discussion, converge on exactly one outcome
|
||||||
|
|
||||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||||
already shipped or tracked. Link the code, doc, or issue; mark answered.
|
already shipped or already tracked. Reply with the answer (link the code,
|
||||||
|
the doc, the existing issue), mark answered.
|
||||||
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||||
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||||
answers would let you write the issue — then stop and wait. Do not mint an
|
answers would let you write the issue — then stop and wait. Do not mint an
|
||||||
|
|
@ -31,13 +35,14 @@ from guessing.
|
||||||
3. **Escalate.** The pending thing is a decision only a human owns — org
|
3. **Escalate.** The pending thing is a decision only a human owns — org
|
||||||
policy, published artifacts, secrets, prod, or any choice whose cost lands
|
policy, published artifacts, secrets, prod, or any choice whose cost lands
|
||||||
outside the work. A panel deadlock is one instance, not the definition
|
outside the work. A panel deadlock is one instance, not the definition
|
||||||
(#50 D11). Say precisely what the decision is, name the decider, and use
|
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say
|
||||||
|
precisely what the decision is, name the decider, and use
|
||||||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||||
including its options, recommendation, blocked/continues statement, and
|
including its options, recommendation, blocked/continues statement, and
|
||||||
reversible-only default rules (#50 D12–D13).
|
reversible-only default rules ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||||
The proposal is where humans decide; wait there. When the decision
|
The discussion is where humans decide; wait there. When the decision
|
||||||
blocks something already on the board — an existing issue, or minted work
|
blocks something already on the board — an existing issue, or minted work
|
||||||
a proposal's ruling gates — set `needs-ruling` on it too, so the board
|
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
||||||
shows where the human's turn is; the issue keeps its queue label.
|
shows where the human's turn is; the issue keeps its queue label.
|
||||||
When you direct a builder to hold a claim, say the claim is **parked**,
|
When you direct a builder to hold a claim, say the claim is **parked**,
|
||||||
name what it waits on, and set `attention` so the assignee's ack is visible
|
name what it waits on, and set `attention` so the assignee's ack is visible
|
||||||
|
|
@ -48,23 +53,26 @@ from guessing.
|
||||||
`needs-ruling` ask — re-read that issue's **label events**
|
`needs-ruling` ask — re-read that issue's **label events**
|
||||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
|
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
|
||||||
comments: the answer often arrives as a label with no comment, and a
|
comments: the answer often arrives as a label with no comment, and a
|
||||||
write that re-read only the thread races it (#149, #151).
|
write that re-read only the thread races it. Both 2026-07-24 failures —
|
||||||
|
[a header correction on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070758613)
|
||||||
|
asserting a hold 58 seconds after its lift, and
|
||||||
|
[a `needs-ruling` ask on #151](https://github.com/heavy-duty/ceremony/issues/151#issuecomment-5070768876)
|
||||||
|
the operator's label events had answered 132 seconds earlier — are this
|
||||||
|
sentence's absence.
|
||||||
Past 24 hours from the current episode's `labeled` event, if the ruling
|
Past 24 hours from the current episode's `labeled` event, if the ruling
|
||||||
still stands and doubt remains, it is triage's duty to pick the option the
|
still stands and doubt remains, it is triage's duty to pick the option the
|
||||||
builder proceeds on, record that pick as a decision, and stay accountable
|
builder proceeds on, record that pick as a decision, and stay accountable
|
||||||
for it; the operator may overturn it at merge (#50 D13–D14). You set the
|
for it; the operator may overturn it at merge
|
||||||
flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)). You set
|
||||||
|
the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
||||||
agreement is reached, record the ruling as a decision in one comment,
|
agreement is reached, record the ruling as a decision in one comment,
|
||||||
remove the label, and return the issue to its flow in that same comment;
|
remove the label, and return the issue to its flow in that same comment;
|
||||||
when that ruling or any directive or answered builder question delivers
|
when that ruling or any directive or answered builder question delivers
|
||||||
the assignee's next move in prose, set `attention` in the same comment on
|
the assignee's next move in prose, set `attention` in the same comment.
|
||||||
the assigned issue that owns the claim — never on the pull request, even
|
|
||||||
when the comment lives there. Flagging an unassigned issue is a board bug,
|
|
||||||
not a demand; repair the board rather than setting `attention`.
|
|
||||||
This is not a substitute for minting work or for `needs-ruling`.
|
This is not a substitute for minting work or for `needs-ruling`.
|
||||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||||
outcome; a zombie proposal is not.
|
outcome; a zombie discussion is not.
|
||||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||||
the bar.
|
the bar.
|
||||||
|
|
||||||
|
|
@ -74,7 +82,7 @@ Every issue you mint carries, in this order:
|
||||||
|
|
||||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||||
abstraction, two backends", never "improve version handling".
|
abstraction, two backends", never "improve version handling".
|
||||||
- **Context**: why this exists, with links — the proposal it came from,
|
- **Context**: why this exists, with links — the discussion it came from,
|
||||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||||
rot), prior art in sibling repos.
|
rot), prior art in sibling repos.
|
||||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||||
|
|
@ -85,35 +93,15 @@ Every issue you mint carries, in this order:
|
||||||
A criterion that can only be checked after the merge must carry its own
|
A criterion that can only be checked after the merge must carry its own
|
||||||
mechanism, in the criterion itself: that it is post-merge, that triage
|
mechanism, in the criterion itself: that it is post-merge, that triage
|
||||||
owns the close, and that the PR references the issue with `Refs #N`
|
owns the close, and that the PR references the issue with `Refs #N`
|
||||||
rather than `Closes #N`; relying on somebody to reopen the issue is an
|
rather than `Closes #N`. A criterion that survives the merge only if
|
||||||
incomplete criterion (#151). The merge moves the issue to `post-merge` and
|
someone remembers to reopen the issue is an incomplete criterion — #137's
|
||||||
releases the claim. The sweep writes the transition comment when it derives
|
amended body is the worked example, reopened by hand after `Closes #137`
|
||||||
the move; on a hand move, triage writes the comment in the same tick. In
|
closed it with the criterion unmet (#151).
|
||||||
either case triage follows up with the remaining criteria, their owner, and
|
|
||||||
the wake condition for completion.
|
|
||||||
- **Test plan**: what proves it, including the cases that must fail.
|
- **Test plan**: what proves it, including the cases that must fail.
|
||||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||||
epic organizes it. Name a cross-repo dependency the same way with its
|
epic organizes it. Name a cross-repo dependency the same way with its
|
||||||
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
||||||
cannot resolve it, so triage verifies it and flips the issue by hand.
|
cannot resolve it, so triage verifies it and flips the issue by hand.
|
||||||
When a deliverable is already carried by an open `ready`, `claimed`, or
|
|
||||||
`blocked` issue, the newer issue must declare an unconditional collision
|
|
||||||
edge with `Blocked by #N`, naming the newest open carrier; there is no
|
|
||||||
alternative for disjoint regions. This keeps every `ready` issue
|
|
||||||
concurrently claimable and makes each close release one successor (#288).
|
|
||||||
During a standing release window, every mint also gets a binary membership
|
|
||||||
call in the same tick. A non-member names the release issue as its blocker
|
|
||||||
in its own Dependencies. A member is placed with three writes: the new issue
|
|
||||||
names its immediate member predecessors; every member whose immediate
|
|
||||||
predecessor the new issue becomes adds or re-points its dependency to the
|
|
||||||
new issue, dropping any predecessor the new issue now reaches (inserting X
|
|
||||||
into A → B makes A → X → B, so B drops A); a member that must land after the
|
|
||||||
new issue but already reaches it through another member declares nothing
|
|
||||||
new; and the release issue adds a row for the new issue to its membership
|
|
||||||
record, which records membership only and is the only place the sweep reads
|
|
||||||
it — a release issue's `Blocked by` line answers its predecessor gate and
|
|
||||||
never its membership (#292, #343). Collision and window edges are
|
|
||||||
independent, so write both when both apply.
|
|
||||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||||
|
|
||||||
|
|
@ -125,14 +113,10 @@ expected.
|
||||||
## Multi-issue work
|
## Multi-issue work
|
||||||
|
|
||||||
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||||
label) with the approach, decisions, constraints, and a dependency-ordered
|
label): the approach, the decisions, the constraint list, and a
|
||||||
child checklist. Children reference the epic; that checklist is the progress
|
dependency-ordered task list of child issues. Children reference the epic;
|
||||||
view. For every epic, put it under a heading
|
the epic's checklist is the progress view. Builders never pick the epic
|
||||||
literally `## Task list`, matched case-insensitively with nothing but optional
|
|
||||||
trailing whitespace; any other heading is invisible to the sweep and draws
|
|
||||||
neither a warning nor a completion nudge (#266). Builders never pick the epic
|
|
||||||
itself. Keep the checklist current — a stale epic misleads every scan.
|
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||||
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
|
||||||
|
|
||||||
## Backlog hygiene
|
## Backlog hygiene
|
||||||
|
|
||||||
|
|
@ -142,19 +126,19 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
||||||
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
||||||
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
||||||
+ no activity → comment, unassign, restore `ready`.
|
+ no activity → comment, unassign, restore `ready`.
|
||||||
- `post-merge` is triage's completion queue, not a parked claim. Tick verified
|
|
||||||
criteria and close under the criterion's existing contract. If corrective
|
|
||||||
build work becomes necessary, move it to `ready` or mint a fresh `ready`
|
|
||||||
issue: any builder claims from current `main`, the original builder has no
|
|
||||||
special standing, and re-entry does not set `attention`.
|
|
||||||
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
||||||
malformed queue states, and close or extend completed epics when nudged.
|
malformed queue states, and close or extend completed epics when nudged.
|
||||||
- **Close obsolete issues** with the reason and a link to what obsoleted
|
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||||
them. Every label on every open issue stays true; the board is only worth
|
them. Every label on every open issue stays true; the board is only worth
|
||||||
scanning if it does not lie.
|
scanning if it does not lie.
|
||||||
- **A lifted hold makes its body prose stale in the same instant, and the
|
- **A lifted hold makes its body prose stale in the same instant, and the
|
||||||
body is yours.** When a hold lifts, correct the body header that described
|
body is yours.** The "stays true" bar above extends past the labels to
|
||||||
it in the same tick — do not leave it to the builder or next reader (#149).
|
the prose that describes them: when a hold lifts, correcting the body
|
||||||
|
header that described it is your move in the same tick — not the
|
||||||
|
builder's, and not left for the next reader to diff. On
|
||||||
|
[#149](https://github.com/heavy-duty/ceremony/issues/149) the lift
|
||||||
|
arrived by label alone and the body said held for the next five and a
|
||||||
|
half minutes; two builders read that window to opposite conclusions.
|
||||||
|
|
||||||
## What you never do
|
## What you never do
|
||||||
|
|
||||||
|
|
@ -162,4 +146,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
||||||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||||
refusal into a vague issue to avoid saying no.
|
refusal into a vague issue to avoid saying no.
|
||||||
- Mint a work issue to explore an idea — file a proposal instead.
|
- Mint an issue to "discuss" something — that is a discussion.
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.6.4-dev
|
0.3.0
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,6 @@ set -euo pipefail
|
||||||
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
||||||
# heading — so the date HEAD stamped into its heading never enters the
|
# heading — so the date HEAD stamped into its heading never enters the
|
||||||
# comparison, and a date difference can never masquerade as a prose one.
|
# comparison, and a date difference can never masquerade as a prose one.
|
||||||
#
|
|
||||||
# This guard narrows, but cannot close, the target-movement window: it sees a
|
|
||||||
# fragment present when CI reads the target ref, but one can still land after
|
|
||||||
# the final run and before merge. Requiring release PRs to be up to date with
|
|
||||||
# their target branch before merge is the repository setting that closes that
|
|
||||||
# residual window (#253).
|
|
||||||
|
|
||||||
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
|
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
|
||||||
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
||||||
|
|
@ -175,37 +169,6 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
|
|
||||||
# Refusal: the target branch gained a fragment after this release PR's merge
|
|
||||||
# base, so the ceremony could not have consumed it. Merging this tree would
|
|
||||||
# strand that fragment for the next release and misattribute when it shipped.
|
|
||||||
stranded=""
|
|
||||||
while IFS= read -r -d '' entry; do
|
|
||||||
meta="${entry%%$'\t'*}"
|
|
||||||
path="${entry#*$'\t'}"
|
|
||||||
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
|
|
||||||
name="${path##*/}"
|
|
||||||
case "$otype:$name" in
|
|
||||||
blob:README.md) ;;
|
|
||||||
blob:*.md)
|
|
||||||
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
|
|
||||||
stranded="${stranded} ${path}"$'\n'
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < <(git ls-tree -z "$base_ref" -- "$dir/")
|
|
||||||
if [ -n "$stranded" ]; then
|
|
||||||
{
|
|
||||||
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
|
|
||||||
echo
|
|
||||||
printf '%s' "$stranded"
|
|
||||||
echo
|
|
||||||
echo " Merging now would strand these entries for the next release and"
|
|
||||||
echo " misattribute when they shipped."
|
|
||||||
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
|
|
||||||
} >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
|
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
|
||||||
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
||||||
# survives its own release sits in the directory and is assembled AGAIN
|
# survives its own release sits in the directory and is assembled AGAIN
|
||||||
|
|
|
||||||
|
|
@ -148,32 +148,11 @@ if [ -n "$source_dir" ]; then
|
||||||
else
|
else
|
||||||
# The repo is public: a plain tarball fetch, no auth, no git. Works for a
|
# The repo is public: a plain tarball fetch, no auth, no git. Works for a
|
||||||
# tag, a branch, or a commit SHA alike.
|
# tag, a branch, or a commit SHA alike.
|
||||||
#
|
|
||||||
# THE FORGE COMES FROM THE ENVIRONMENT, NEVER FROM THIS FILE (#201).
|
|
||||||
# heavy-duty/ceremony exists on two forges and the same ref names a
|
|
||||||
# DIFFERENT TREE on each: `0.4.1` on this forge carries lib/forge.sh,
|
|
||||||
# lib/forge-github.sh and lib/forge-forgejo.sh; GitHub's `0.4.1` carries
|
|
||||||
# none of them. A hard-coded host therefore verified a consumer's mirror
|
|
||||||
# against a tree it never pinned — and did it with HTTP 200, so --check
|
|
||||||
# reported drift the consumer could not fix and --fix would have rewritten
|
|
||||||
# a correct mirror into the wrong one. The version numbers agreeing is the
|
|
||||||
# hazard, not the protection (#197 decision 2).
|
|
||||||
#
|
|
||||||
# GITHUB_SERVER_URL is what Actions injects on both forges, and
|
|
||||||
# lib/forge.sh already selects the whole backend on it — so a consumer run
|
|
||||||
# that reached this line has it. Unset means we do not know which forge the
|
|
||||||
# pin refers to, and guessing is what this issue is about: refuse instead,
|
|
||||||
# the same way the pin itself is never guessed.
|
|
||||||
[ -n "${GITHUB_SERVER_URL:-}" ] || die \
|
|
||||||
"docs-sync: GITHUB_SERVER_URL is unset, so the forge holding" \
|
|
||||||
" heavy-duty/ceremony@$ref is unknown — and the same ref names a" \
|
|
||||||
" different tree on each forge. Set it to the forge this consumer is" \
|
|
||||||
" pinned against, or pass --source <dir>. This tool never guesses a forge."
|
|
||||||
fetch_tmp="$(mktemp -d)"
|
fetch_tmp="$(mktemp -d)"
|
||||||
url="${GITHUB_SERVER_URL%/}/heavy-duty/ceremony/archive/${ref}.tar.gz"
|
url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz"
|
||||||
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
|
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
|
||||||
"docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
|
"docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
|
||||||
" does the pinned ref exist on that forge?"
|
" does the pinned ref exist?"
|
||||||
src="$fetch_tmp"
|
src="$fetch_tmp"
|
||||||
origin="heavy-duty/ceremony@$ref"
|
origin="heavy-duty/ceremony@$ref"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -23,8 +23,7 @@ runs:
|
||||||
BOOTSTRAP: ${{ inputs.bootstrap }}
|
BOOTSTRAP: ${{ inputs.bootstrap }}
|
||||||
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
|
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
|
||||||
run: |
|
run: |
|
||||||
# BOOTSTRAP passes through as-is: the script gates on the input. The
|
if [ "$BOOTSTRAP" = yes ]; then
|
||||||
# export-the-event-name hack that lived here died with ceremony#215 —
|
export GITHUB_EVENT_NAME=workflow_dispatch
|
||||||
# the script keyed on GITHUB_EVENT_NAME, which #209 made true for
|
fi
|
||||||
# every machine wake, so "no" could never mean no.
|
|
||||||
bash "$GITHUB_ACTION_PATH/labels-reconcile.sh"
|
bash "$GITHUB_ACTION_PATH/labels-reconcile.sh"
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,8 @@ fi
|
||||||
# stale approval must never promote unreviewed code to the human.
|
# stale approval must never promote unreviewed code to the human.
|
||||||
#
|
#
|
||||||
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
|
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
|
||||||
# is rehearsed against the live repo). A run with BOOTSTRAP=yes also
|
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
|
||||||
# bootstraps the taxonomy (label create --force) — the operator's manual
|
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
|
||||||
# dispatch defaults the input to yes; every machine wake passes no. The cron
|
|
||||||
# sweep tolerates a missing label rather than recreating it.
|
# sweep tolerates a missing label rather than recreating it.
|
||||||
#
|
#
|
||||||
# The state machine below is pure (globals in, state out) and covered by
|
# The state machine below is pure (globals in, state out) and covered by
|
||||||
|
|
@ -38,12 +37,6 @@ fi
|
||||||
|
|
||||||
HUMAN="${HUMAN_REVIEWER:-danmt}"
|
HUMAN="${HUMAN_REVIEWER:-danmt}"
|
||||||
BOTS=()
|
BOTS=()
|
||||||
# Per-author panels (#224): parallel arrays because the conf is tiny and an
|
|
||||||
# associative array buys nothing but a bash-4 dependency statement. One entry
|
|
||||||
# per panel[<login>]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the
|
|
||||||
# space-joined reviewer set at the same index.
|
|
||||||
PANEL_AUTHORS=()
|
|
||||||
PANEL_ROWS=()
|
|
||||||
REQUIRED_BOTS=()
|
REQUIRED_BOTS=()
|
||||||
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
|
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
|
||||||
BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested)
|
BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested)
|
||||||
|
|
@ -56,36 +49,10 @@ LABELS=""
|
||||||
# retirement heals the board instead of stranding a label nothing recomputes.
|
# retirement heals the board instead of stranding a label nothing recomputes.
|
||||||
RETIRED=(state:needs-rebase)
|
RETIRED=(state:needs-rebase)
|
||||||
STALE_AFTER=$((48 * 3600))
|
STALE_AFTER=$((48 * 3600))
|
||||||
# How long the facts behind blocker:unrequested must have stood still before it
|
|
||||||
# is written (#236 D2). The operator's "more than 5 minutes", measured off the
|
|
||||||
# inputs' own timestamps rather than off sweep memory — this script is
|
|
||||||
# stateless per pass and stays that way. Overridable the way this file's other
|
|
||||||
# constants are, for a caller whose round cadence is slower or faster.
|
|
||||||
RECONCILE_UNREQUESTED_GRACE="${RECONCILE_UNREQUESTED_GRACE:-300}"
|
|
||||||
# The workflow whose runs checks_state must never grade — its own (#208).
|
|
||||||
# GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the
|
|
||||||
# consumer's PR-facing workflow, since consumers name the caller), so this
|
|
||||||
# self-serves with no workflow-file change. The explicit override exists for
|
|
||||||
# two readers: the fixtures, and #209's detached sweep caller, which will
|
|
||||||
# need to point this at the PR-facing caller's name once reconcile no longer
|
|
||||||
# runs inside it. Empty means "filter nothing" — a caller outside Actions
|
|
||||||
# (a local rehearsal, an older pin) must not silently start dropping entries.
|
|
||||||
SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}"
|
|
||||||
|
|
||||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
||||||
# The attention target invariants (#232) — diagnosis only, both surfaces.
|
|
||||||
# shellcheck source=lib/attention.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
|
|
||||||
# The guarded read and its reason line (#101) — one implementation for both
|
|
||||||
# surfaces. read_failure_reason lived here until the issue surface needed the
|
|
||||||
# identical rule (#247); a second copy of it is the failure lib/ruling.sh's
|
|
||||||
# own header was written to record.
|
|
||||||
# shellcheck source=lib/read.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh"
|
|
||||||
|
|
||||||
log() { printf 'labels: %s\n' "$*"; }
|
log() { printf 'labels: %s\n' "$*"; }
|
||||||
|
|
||||||
|
|
@ -103,13 +70,32 @@ blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled r
|
||||||
if [ "$2" -gt 0 ] && [ "$1" -eq "$2" ]; then
|
if [ "$2" -gt 0 ] && [ "$1" -eq "$2" ]; then
|
||||||
local reason="${3:-}"
|
local reason="${3:-}"
|
||||||
if [ -n "$reason" ]; then
|
if [ -n "$reason" ]; then
|
||||||
echo "::warning::labels: every open PR was unreadable; sampled reason: $reason — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)"
|
echo "::warning::labels: every open PR was unreadable; sampled reason: $reason — one candidate is missing checks: read and statuses: read in the caller (private repos do not imply them)"
|
||||||
else
|
else
|
||||||
echo "::warning::labels: every open PR was unreadable; no reason was captured — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)"
|
echo "::warning::labels: every open PR was unreadable; no reason was captured — one candidate is missing checks: read and statuses: read in the caller (private repos do not imply them)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
|
||||||
|
# Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL
|
||||||
|
# blobs. Collapsed so the reason is exactly one log line — a raw newline
|
||||||
|
# inside the captured per-PR output block could collide with a matched
|
||||||
|
# string — and truncated because an unbounded paste per PR per sweep is
|
||||||
|
# noise, and annotations are capped anyway.
|
||||||
|
local reason
|
||||||
|
reason="$(printf '%s' "${1-}" | tr '\n' ' ')"
|
||||||
|
if [ -z "$reason" ]; then
|
||||||
|
# Empty stderr is itself a fact (D4): a read that failed silently is a
|
||||||
|
# different observation from a denial, and must not read as one.
|
||||||
|
echo "no error output"
|
||||||
|
elif [ "${#reason}" -gt 300 ]; then
|
||||||
|
printf '%s…\n' "${reason:0:300}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$reason"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names
|
missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names
|
||||||
local rows="$1" repo_labels="$2" row name missing=""
|
local rows="$1" repo_labels="$2" row name missing=""
|
||||||
[ -n "$repo_labels" ] || return 0
|
[ -n "$repo_labels" ] || return 0
|
||||||
|
|
@ -132,16 +118,8 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
BOTS=()
|
BOTS=()
|
||||||
PANEL_AUTHORS=()
|
|
||||||
PANEL_ROWS=()
|
|
||||||
# shellcheck disable=SC2094 # parse_panel_author_row takes $conf for its
|
|
||||||
# error messages only — nothing in this loop writes the file it reads
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
[ -n "$line" ] || continue
|
[ -n "$line" ] || continue
|
||||||
# The panel[ prefix is matched QUOTED (#224 D7): in a case pattern an
|
|
||||||
# unquoted panel[abc]=* is a bracket expression that matches panela=…,
|
|
||||||
# panelb=…, panelc=… — silently rerouting ordinary settings. The
|
|
||||||
# panela= tripwire in test/labels.test.sh goes red if this regresses.
|
|
||||||
case "$line" in
|
case "$line" in
|
||||||
panel=*)
|
panel=*)
|
||||||
[ "$panel_seen" = false ] || {
|
[ "$panel_seen" = false ] || {
|
||||||
|
|
@ -155,7 +133,6 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
;;
|
;;
|
||||||
"panel["*) parse_panel_author_row "$line" "$conf" || return ;;
|
|
||||||
triage-actors=*) ;;
|
triage-actors=*) ;;
|
||||||
*) parse_label_row "$line" >/dev/null || return ;;
|
*) parse_label_row "$line" >/dev/null || return ;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -166,55 +143,6 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_panel_author_row() { # panel[<login>]=<space-separated logins> (#224)
|
|
||||||
# Every failure here is a hard one that names the offending line (D3): a
|
|
||||||
# conf error takes the whole board down, and the run log is the only place
|
|
||||||
# the operator can read why. A malformed bracket is refused AS a bracket
|
|
||||||
# (D4) — falling through to parse_label_row would report it as a
|
|
||||||
# "malformed label row", the misleading diagnostic #224 was filed over.
|
|
||||||
local line="$1" conf="$2" login rest existing
|
|
||||||
case "$line" in
|
|
||||||
"panel["*"]="*) ;;
|
|
||||||
*)
|
|
||||||
echo "labels: malformed panel[<login>]= row (expected panel[<login>]=<reviewers>): $line in $conf" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
login="${line#panel[}"
|
|
||||||
login="${login%%]=*}"
|
|
||||||
[ -n "$login" ] || {
|
|
||||||
echo "labels: empty login in panel row: $line in $conf" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
# The login must be exactly one well-formed bracket pair of login
|
|
||||||
# characters. Without this, panel[z]]=b parses: the case above only
|
|
||||||
# establishes that SOME ]= occurs, ${login%%]=*} keeps the stray ] inside
|
|
||||||
# the login (z]), and set_required_bots for the real z then silently falls
|
|
||||||
# back to the base panel — the misroute D4 exists to refuse. GitHub logins
|
|
||||||
# are [A-Za-z0-9-], per the #285 spec.
|
|
||||||
case "$login" in
|
|
||||||
*[!A-Za-z0-9-]*)
|
|
||||||
echo "labels: malformed panel[<login>]= row (a login is [A-Za-z0-9-] only): $line in $conf" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do
|
|
||||||
[ "$existing" != "$login" ] || {
|
|
||||||
echo "labels: duplicate panel[$login]= row in $conf: $line" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
done
|
|
||||||
local -a row=()
|
|
||||||
rest="${line#*]=}"
|
|
||||||
read -r -a row <<<"$rest"
|
|
||||||
[ "${#row[@]}" -gt 0 ] || {
|
|
||||||
echo "labels: panel[$login]= must name at least one reviewer in $conf: $line" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
PANEL_AUTHORS+=("$login")
|
|
||||||
PANEL_ROWS+=("${row[*]}")
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_label_row() { # exact name|color|description; pipes in descriptions are refused
|
parse_label_row() { # exact name|color|description; pipes in descriptions are refused
|
||||||
local line="$1" name color desc extra
|
local line="$1" name color desc extra
|
||||||
IFS='|' read -r name color desc extra <<<"$line"
|
IFS='|' read -r name color desc extra <<<"$line"
|
||||||
|
|
@ -230,38 +158,15 @@ configured_label_rows() { # validated scope rows, excluding the panel setting
|
||||||
[ -f "$conf" ] || return 0
|
[ -f "$conf" ] || return 0
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
[ -n "$line" ] || continue
|
[ -n "$line" ] || continue
|
||||||
# "panel["* quoted for the same D7 reason as load_config's case; skipping
|
case "$line" in panel=* | triage-actors=*) continue ;; esac
|
||||||
# the bracketed rows (D5) keeps a dispatch bootstrap from trying to
|
|
||||||
# create a label named panel[<login>].
|
|
||||||
case "$line" in panel=* | "panel["* | triage-actors=*) continue ;; esac
|
|
||||||
parse_label_row "$line" || return
|
parse_label_row "$line" || return
|
||||||
done <"$conf"
|
done <"$conf"
|
||||||
}
|
}
|
||||||
|
|
||||||
panel_for_author() { # $1 = author → the effective panel, space-joined (#224 D2)
|
|
||||||
# THE resolution point: the author's panel[<login>]= row when the conf
|
|
||||||
# defines one, the base panel= otherwise. Everything that computes a
|
|
||||||
# required set goes through here, because two places computing the panel
|
|
||||||
# is how the engine and the reconciler came to disagree in the first place.
|
|
||||||
local author="$1" i
|
|
||||||
for i in ${PANEL_AUTHORS[@]+"${!PANEL_AUTHORS[@]}"}; do
|
|
||||||
if [ "${PANEL_AUTHORS[i]}" = "$author" ]; then
|
|
||||||
printf '%s\n' "${PANEL_ROWS[i]}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
printf '%s\n' "${BOTS[*]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
set_required_bots() { # the PR author is recused by construction
|
set_required_bots() { # the PR author is recused by construction
|
||||||
# Minus-the-author applies to WHICHEVER set panel_for_author returns (#224
|
|
||||||
# D2's safety net): an author who mistakenly appears inside its own
|
|
||||||
# bracketed row is still recused.
|
|
||||||
local author="$1" bot
|
local author="$1" bot
|
||||||
local -a effective=()
|
|
||||||
read -r -a effective <<<"$(panel_for_author "$author")"
|
|
||||||
REQUIRED_BOTS=()
|
REQUIRED_BOTS=()
|
||||||
for bot in ${effective[@]+"${effective[@]}"}; do
|
for bot in "${BOTS[@]}"; do
|
||||||
[ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot")
|
[ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot")
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
@ -270,15 +175,12 @@ set_required_bots() { # the PR author is recused by construction
|
||||||
# The state machine. Pure functions over these globals, set per PR:
|
# The state machine. Pure functions over these globals, set per PR:
|
||||||
# DRAFT true|false
|
# DRAFT true|false
|
||||||
# HEAD_SHA the PR's current head commit
|
# HEAD_SHA the PR's current head commit
|
||||||
# BASE_SHA the PR's base branch head
|
# BASE_SHA the PR's base branch head (the release-shape guard's ref)
|
||||||
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
|
|
||||||
# REQUESTED newline-separated logins with a review currently requested
|
# REQUESTED newline-separated logins with a review currently requested
|
||||||
# REVIEWS_JSON JSON array of submitted, gradeable reviews
|
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
|
||||||
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
|
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
|
||||||
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
|
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
|
||||||
# LABELS newline-separated labels currently on the PR
|
# LABELS newline-separated labels currently on the PR
|
||||||
# HEAD_COMMIT_AT the head commit's own date, ISO-8601; empty when unread
|
|
||||||
# NOW this sweep's epoch seconds (main sets it once per run)
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||||
|
|
@ -319,27 +221,7 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE |
|
||||||
# a context whose entries are ALL cancelled never reported at all (a killed
|
# a context whose entries are ALL cancelled never reported at all (a killed
|
||||||
# or timed-out required job), so it keeps CANCELLED and still blocks —
|
# or timed-out required job), so it keeps CANCELLED and still blocks —
|
||||||
# discard needs a surviving verdict, never an empty context.
|
# discard needs a surviving verdict, never an empty context.
|
||||||
#
|
jq -r '
|
||||||
# And one exclusion that comes before every rule above: the label machine
|
|
||||||
# never grades its own runs (#208). Every reconcile sweep serializes
|
|
||||||
# through one shared concurrency group, and GitHub records a displaced
|
|
||||||
# queued run as CANCELLED — there is no "superseded" conclusion for queue
|
|
||||||
# displacement. When the displaced run was born from a pull_request_target
|
|
||||||
# event, that cancelled entry attaches to the victim PR while its
|
|
||||||
# SUCCESSOR — triggered by a different PR or an issues event — attaches
|
|
||||||
# elsewhere, so the #139 carve-out's premise (a surviving sibling on the
|
|
||||||
# same PR) fails structurally: on the victim the newest self entry stays
|
|
||||||
# CANCELLED, the deny-list scores it FAILURE, and the sweep sets
|
|
||||||
# blocker:ci-red off its own corpse — then re-affirms it every cadence.
|
|
||||||
# Proven on crew#227: every real check green, the only red rollup entry
|
|
||||||
# the sweep's own displaced run. So drop every entry belonging to
|
|
||||||
# $SELF_WORKFLOW before the newest-per-context collapse. Accepted
|
|
||||||
# consequences: a rollup of ONLY self entries scores NONE (honestly: no
|
|
||||||
# checks — never SUCCESS), and a genuine reconcile failure surfaces on the
|
|
||||||
# Actions tab instead of as blocker:ci-red, which is right because no PR
|
|
||||||
# edit can fix the label machinery. An empty $self filters nothing — the
|
|
||||||
# exclusion must never widen into dropping entries on a guess.
|
|
||||||
jq -r --arg self "$SELF_WORKFLOW" '
|
|
||||||
if (has("statusCheckRollup") | not) then "UNREADABLE" else
|
if (has("statusCheckRollup") | not) then "UNREADABLE" else
|
||||||
|
|
||||||
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
|
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
|
||||||
|
|
@ -380,11 +262,7 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE |
|
||||||
# and treating it as newest keeps an undateable in-flight run from being
|
# and treating it as newest keeps an undateable in-flight run from being
|
||||||
# discarded in favour of a stale success. Every ambiguity resolves toward
|
# discarded in favour of a stale success. Every ambiguity resolves toward
|
||||||
# "not settled".
|
# "not settled".
|
||||||
# The #208 exclusion (header above): self entries leave the rollup here,
|
|
||||||
# BEFORE the group_by — a self-only context must vanish entirely, never
|
|
||||||
# survive as an all-cancelled context that still classifies FAILURE.
|
|
||||||
| [ (.statusCheckRollup // [])[]
|
| [ (.statusCheckRollup // [])[]
|
||||||
| select($self == "" or (.workflowName // "") != $self)
|
|
||||||
| { ctx: [.workflowName // "", .name // .context // ""],
|
| { ctx: [.workflowName // "", .name // .context // ""],
|
||||||
at: ([.startedAt, .createdAt, .completedAt]
|
at: ([.startedAt, .createdAt, .completedAt]
|
||||||
| map(select(type == "string" and . != ""
|
| map(select(type == "string" and . != ""
|
||||||
|
|
@ -416,66 +294,21 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
||||||
if [ -z "$review" ]; then echo MISSING; return; fi
|
if [ -z "$review" ]; then echo MISSING; return; fi
|
||||||
state="$(jq -r '.state' <<<"$review")"
|
state="$(jq -r '.state' <<<"$review")"
|
||||||
commit="$(jq -r '.commit_id' <<<"$review")"
|
commit="$(jq -r '.commit_id' <<<"$review")"
|
||||||
# This case grades a submitted verdict. The ingestion allow-list answers the
|
|
||||||
# separate question of whether a row is a submitted review at all (#235).
|
|
||||||
case "$state" in
|
case "$state" in
|
||||||
CHANGES_REQUESTED | REQUEST_CHANGES)
|
CHANGES_REQUESTED)
|
||||||
# blocks at ANY head — both forges' semantic: only a newer review from
|
# blocks at ANY head — GitHub's own semantic: only a newer review
|
||||||
# the same reviewer clears it
|
# from the same reviewer clears it
|
||||||
echo BLOCK ;;
|
echo BLOCK ;;
|
||||||
APPROVED)
|
APPROVED)
|
||||||
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
||||||
COMMENTED | COMMENT)
|
|
||||||
# A comment is a non-verdict. The machine does not read bodies — if the
|
|
||||||
# comment is really an agreement, the AUTHOR says so by requesting the
|
|
||||||
# human's review.
|
|
||||||
echo FEEDBACK ;;
|
|
||||||
*)
|
*)
|
||||||
# An unknown state is not evidence that a reviewer answered. Keep the
|
# COMMENTED and anything else: a non-verdict. The machine does not
|
||||||
# round open and make the next forge vocabulary surprise visible (#235).
|
# read bodies — if the comment is really an agreement, the AUTHOR
|
||||||
log "$1: unrecognised review state $state" >&2
|
# says so by requesting the human's review.
|
||||||
echo MISSING ;;
|
echo FEEDBACK ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
iso_epoch() { # $1 = ISO-8601 timestamp → epoch seconds; nothing, rc 1, when unreadable
|
|
||||||
# An absent field reaches this as the empty string or as jq's literal "null";
|
|
||||||
# both are "we did not read a time", and neither may be graded as one.
|
|
||||||
local at="${1-}" epoch
|
|
||||||
case "$at" in "" | null) return 1 ;; esac
|
|
||||||
epoch="$(date -d "$at" +%s 2>/dev/null)" || return 1
|
|
||||||
[ -n "$epoch" ] || return 1
|
|
||||||
printf '%s\n' "$epoch"
|
|
||||||
}
|
|
||||||
|
|
||||||
unrequested_quiescent() { # 0 when the unrequested facts have stood for the grace (#236 D2)
|
|
||||||
# The stall blocker's supporting facts are the head and the round's newest
|
|
||||||
# submitted review: the ask it demands is owed only once both have stopped
|
|
||||||
# moving. Measured off those timestamps, not off sweep memory — ceremony#235
|
|
||||||
# was flagged inside the ~90 seconds between a round-answer push and the
|
|
||||||
# author's re-request, because a sweep read the facts before the request
|
|
||||||
# landed and wrote after it. That is a round in motion, not a dropped ball.
|
|
||||||
#
|
|
||||||
# "Newest submitted review" is any submitted review, COMMENTED included: a
|
|
||||||
# non-verdict is still evidence the round is live, and counting it can only
|
|
||||||
# delay a flag, never invent one.
|
|
||||||
#
|
|
||||||
# A timestamp we could not read refuses the blocker (the standing rule: an
|
|
||||||
# unreadable fact never invents a verdict). This direction is deliberate and
|
|
||||||
# asymmetric — a missed flag costs one sweep of the 15-minute cadence, a
|
|
||||||
# false one flags a builder for doing exactly what BUILDER.md requires.
|
|
||||||
local newest verdict_at verdict_epoch
|
|
||||||
newest="$(iso_epoch "${HEAD_COMMIT_AT:-}")" || return 1
|
|
||||||
verdict_at="$(jq -r '[.[].submitted_at] | max // empty' <<<"${REVIEWS_JSON:-[]}")"
|
|
||||||
if [ -n "$verdict_at" ]; then
|
|
||||||
# A round WITH verdicts whose newest one cannot be dated is unreadable, not
|
|
||||||
# quiescent; a round with no verdicts at all is simply the head's clock.
|
|
||||||
verdict_epoch="$(iso_epoch "$verdict_at")" || return 1
|
|
||||||
[ "$verdict_epoch" -gt "$newest" ] && newest="$verdict_epoch"
|
|
||||||
fi
|
|
||||||
[ $((${NOW:-0} - newest)) -ge "$RECONCILE_UNREQUESTED_GRACE" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
human_request_needed() { # 0 when needs-human requires a FRESH human request
|
human_request_needed() { # 0 when needs-human requires a FRESH human request
|
||||||
# already requested → the handoff is live; head-current human approval →
|
# already requested → the handoff is live; head-current human approval →
|
||||||
# nothing left to ask. Anything else (never reviewed, an old comment, an
|
# nothing left to ask. Anything else (never reviewed, an old comment, an
|
||||||
|
|
@ -511,27 +344,7 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
|
||||||
# A draft is exempt (the bots ignore drafts by design), and so is an
|
# A draft is exempt (the bots ignore drafts by design), and so is an
|
||||||
# explicit human request — a maintainer claiming a PR early is deliberate,
|
# explicit human request — a maintainer claiming a PR early is deliberate,
|
||||||
# not a dropped ball.
|
# not a dropped ball.
|
||||||
#
|
if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then
|
||||||
# And so is a head whose checks have not answered yet (#236 D1). This is the
|
|
||||||
# one blocker that names an act the author must PERFORM, so it is the one
|
|
||||||
# that has to know when performing it is permitted: BUILDER.md's review round
|
|
||||||
# requires a green check at the head before requesting, so a builder waiting
|
|
||||||
# out a pending run is complying, and flagging compliance teaches its readers
|
|
||||||
# to ignore the label. Both 2026-08-03 instances were exactly that —
|
|
||||||
# crew#318 at ~12:44Z carried state:addressing + blocker:unrequested while
|
|
||||||
# the head's run was IN_PROGRESS, and ceremony#235 at 12:30Z caught the
|
|
||||||
# ~90-second gap between a round-answer push and the re-request.
|
|
||||||
#
|
|
||||||
# PENDING and FAILURE each already have an owner, which is why gating loses
|
|
||||||
# no coverage: on PENDING the next move is CI's and state:addressing /
|
|
||||||
# state:bots-reviewing already say what the PR is doing; on FAILURE
|
|
||||||
# blocker:ci-red owns that head, and stacking a second blocker on it
|
|
||||||
# double-flags one stall. NONE joins SUCCESS because no checks configured is
|
|
||||||
# nothing to wait for — the same reading the request rule gives the builder.
|
|
||||||
# UNREADABLE never arrives here: the caller skips the PR before deciding.
|
|
||||||
local checks_permit_the_ask=false
|
|
||||||
case "${CHECKS:-NONE}" in SUCCESS | NONE) checks_permit_the_ask=true ;; esac
|
|
||||||
if [ "$DRAFT" != true ] && [ "$checks_permit_the_ask" = true ] && ! requested "$HUMAN"; then
|
|
||||||
local b v owed=false any_requested=false
|
local b v owed=false any_requested=false
|
||||||
for b in "${REQUIRED_BOTS[@]}"; do
|
for b in "${REQUIRED_BOTS[@]}"; do
|
||||||
requested "$b" && any_requested=true
|
requested "$b" && any_requested=true
|
||||||
|
|
@ -542,64 +355,18 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
|
||||||
v="$(bot_verdict "$b")"
|
v="$(bot_verdict "$b")"
|
||||||
case "$v" in MISSING | STALE) owed=true ;; esac
|
case "$v" in MISSING | STALE) owed=true ;; esac
|
||||||
done
|
done
|
||||||
# The quiescence grace (#236 D2) is the last question, after the debt is
|
if [ "$owed" = true ] && [ "$any_requested" = false ]; then
|
||||||
# established: it asks whether the debt has stood long enough to be a
|
|
||||||
# dropped ball rather than a round still in motion.
|
|
||||||
if [ "$owed" = true ] && [ "$any_requested" = false ] && unrequested_quiescent; then
|
|
||||||
echo blocker:unrequested
|
echo blocker:unrequested
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
round_outranks_draft() { # 0 when the round's standing word survives a re-draft (#205)
|
|
||||||
# A standing non-approving verdict outranks draft: a PR that took a round,
|
|
||||||
# carries CHANGES_REQUESTED (or a comment owed a reply, or approvals a push
|
|
||||||
# staled), and is then converted back to draft is a fix round in progress,
|
|
||||||
# not a build — and hiding it behind state:building is a dropped ball the
|
|
||||||
# staleness sweep reads as work in progress. Approvals do NOT outrank
|
|
||||||
# draft: a re-draft after a passed round is deliberately building again,
|
|
||||||
# and a draft must never read state:needs-human.
|
|
||||||
#
|
|
||||||
# A LIVE panel request on a draft also falls through — deliberately
|
|
||||||
# surfaced, not absorbed (#205's must-not-paper-over): the bots ignore
|
|
||||||
# drafts by design, so a draft wearing state:bots-reviewing on the board
|
|
||||||
# is the visible symptom of a real defect (a request nobody cleared at
|
|
||||||
# round close, or a hand-requested draft), and reading it as building
|
|
||||||
# would hide exactly that.
|
|
||||||
local b
|
|
||||||
for b in "${REQUIRED_BOTS[@]}"; do
|
|
||||||
requested "$b" && return 0
|
|
||||||
case "$(bot_verdict "$b")" in BLOCK | FEEDBACK | STALE) return 0 ;; esac
|
|
||||||
done
|
|
||||||
[ "$(bot_verdict "$HUMAN")" = BLOCK ]
|
|
||||||
}
|
|
||||||
|
|
||||||
decide_state() { # → the one state:* label this PR should carry
|
decide_state() { # → the one state:* label this PR should carry
|
||||||
# Draft decides the state only when the round implies nothing else (#205):
|
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
||||||
# a draft with no round history reads state:building exactly as it always
|
|
||||||
# has, and round_outranks_draft is what "nothing else" means.
|
|
||||||
if [ "$DRAFT" = true ] && ! round_outranks_draft; then
|
|
||||||
echo state:building
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local s
|
local s
|
||||||
s="$(round_state)"
|
s="$(round_state)"
|
||||||
|
|
||||||
# A draft disqualifies needs-human unconditionally (#205, round 1): with
|
|
||||||
# the short-circuit above now conditional, a draft carrying a live human
|
|
||||||
# request plus a standing bot block or comment fell through to
|
|
||||||
# round_state, whose explicit-human-request precedence sits above the
|
|
||||||
# BLOCK/FEEDBACK cases — and GitHub cannot merge a draft at all, so
|
|
||||||
# "a human could merge this right now" would lie no matter what the
|
|
||||||
# round says. state:addressing is the same honest landing the blocker/
|
|
||||||
# needs-ruling/blocked clauses below use: the round's word stands, only
|
|
||||||
# the mergeable-now claim is off the table while the PR is a draft.
|
|
||||||
if [ "$s" = state:needs-human ] && [ "$DRAFT" = true ]; then
|
|
||||||
echo state:addressing
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The one rule joining the two axes: state:needs-human means a human could
|
# The one rule joining the two axes: state:needs-human means a human could
|
||||||
# merge this RIGHT NOW, so it requires a clear branch. Any blocker at all
|
# merge this RIGHT NOW, so it requires a clear branch. Any blocker at all
|
||||||
# means the work is the agent's — whatever the review round says — and the
|
# means the work is the agent's — whatever the review round says — and the
|
||||||
|
|
@ -622,20 +389,6 @@ decide_state() { # → the one state:* label this PR should carry
|
||||||
if [ "$s" = state:needs-human ] && has_label needs-ruling; then
|
if [ "$s" = state:needs-human ] && has_label needs-ruling; then
|
||||||
echo state:addressing; return
|
echo state:addressing; return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A directed hold disqualifies it the same way (#180): `blocked` is hand-set
|
|
||||||
# intent — triage sets it, anyone may correct it — and during the #111
|
|
||||||
# freeze rig#126/#128 carried it beside state:needs-human, so the board said
|
|
||||||
# "mergeable right now" about PRs a hold said must not merge (rig#126 was
|
|
||||||
# merged seven minutes later). Not a BLOCKERS entry, deliberately: that
|
|
||||||
# array is machine-owned and the converge loop strips whatever the facts do
|
|
||||||
# not re-derive, so emitting the label there would strip a live hold on the
|
|
||||||
# next 15-minute tick — the same trap #51 names for `needs-ruling`.
|
|
||||||
# state:addressing is the accepted imprecision: under a hold the builder
|
|
||||||
# owes nothing, but "a human could merge this now" must not lie.
|
|
||||||
if [ "$s" = state:needs-human ] && has_label blocked; then
|
|
||||||
echo state:addressing; return
|
|
||||||
fi
|
|
||||||
echo "$s"
|
echo "$s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -702,7 +455,7 @@ round_state() { # → the state the REVIEW ROUND alone implies; knows no branch
|
||||||
|
|
||||||
core_label_rows() {
|
core_label_rows() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
state:building|FBCA04|Pre-round: the builder is still building — draft is evidence for it, not the definition
|
state:building|FBCA04|PR is a draft — the coding agent is still building
|
||||||
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
|
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
|
||||||
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
|
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
|
||||||
state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer
|
state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer
|
||||||
|
|
@ -716,15 +469,14 @@ offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock p
|
||||||
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment
|
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment
|
||||||
attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label
|
attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label
|
||||||
release|0E8A16|Release flow and version/packaging work
|
release|0E8A16|Release flow and version/packaging work
|
||||||
needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal
|
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion
|
||||||
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
|
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
|
||||||
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
|
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
|
||||||
post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
|
|
||||||
epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it
|
epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` belongs in a proposal, not a work issue
|
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` is a discussion
|
||||||
# One registry, kept beside core_label_rows() for the same reason those rows
|
# One registry, kept beside core_label_rows() for the same reason those rows
|
||||||
# are not in labels.conf: a rule that must hold in every governed repo
|
# are not in labels.conf: a rule that must hold in every governed repo
|
||||||
# cannot live in a per-repo file. The six names match LABELS.md exactly.
|
# cannot live in a per-repo file. The six names match LABELS.md exactly.
|
||||||
|
|
@ -747,7 +499,7 @@ $(configured_label_rows "$LABELS_CONF")"
|
||||||
fi
|
fi
|
||||||
while IFS='|' read -r name color desc; do
|
while IFS='|' read -r name color desc; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
run forge_label_create "$name" "$color" "$desc"
|
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
||||||
done <<<"$rows"
|
done <<<"$rows"
|
||||||
|
|
||||||
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
|
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
|
||||||
|
|
@ -761,7 +513,7 @@ $(configured_label_rows "$LABELS_CONF")"
|
||||||
# the taxonomy it can create. Either way: log the name, keep going.
|
# the taxonomy it can create. Either way: log the name, keep going.
|
||||||
while IFS= read -r name; do
|
while IFS= read -r name; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
run forge_label_delete "$name" \
|
run gh label delete "$name" -R "$REPO" --yes \
|
||||||
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
|
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
|
||||||
done <<<"$(retired_label_names)"
|
done <<<"$(retired_label_names)"
|
||||||
}
|
}
|
||||||
|
|
@ -790,37 +542,16 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing
|
||||||
# Every failure path prints nothing: the caller treats "could not read"
|
# Every failure path prints nothing: the caller treats "could not read"
|
||||||
# as "not release-shaped" rather than warning on a guess.
|
# as "not release-shaped" rather than warning on a guess.
|
||||||
local ref="$1" ver
|
local ref="$1" ver
|
||||||
ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
|
ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
|
||||||
| base64 -d 2>/dev/null | tr -d '[:space:]')"
|
| base64 -d 2>/dev/null | tr -d '[:space:]')"
|
||||||
if [ -z "$ver" ]; then
|
if [ -z "$ver" ]; then
|
||||||
ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
|
ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
|
||||||
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
|
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
|
||||||
fi
|
fi
|
||||||
[ -z "$ver" ] || printf '%s\n' "$ver"
|
[ -z "$ver" ] || printf '%s\n' "$ver"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# label_write <n> <args…> — every label mutation on this surface goes through
|
|
||||||
# here (#192). A write that did not happen must reach main's exit code, and the
|
|
||||||
# first version of this fix marked only the primary state edit: clearing
|
|
||||||
# `merge-next` and the two `stale` edits could still fail into the generic
|
|
||||||
# per-PR branch and finish with `reconciled.` and exit 0
|
|
||||||
# (@codex-reviewer-andresmgsl). One helper means a future call site cannot
|
|
||||||
# reopen that by forgetting to mark itself.
|
|
||||||
#
|
|
||||||
# The marker is a log line rather than a return code because reconcile_pr runs
|
|
||||||
# in a subshell whose STDOUT main reads — the same channel the degraded-read
|
|
||||||
# warning already travels on.
|
|
||||||
label_write() {
|
|
||||||
local n="$1"
|
|
||||||
shift
|
|
||||||
if run forge_issue_edit "$n" "$@" >/dev/null; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
log "#$n: label edit FAILED — attempted: forge_issue_edit $n $*; the write did not happen (reason on stderr above)"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
local n="$1" desired remove s args last_activity last_activity_epoch age
|
local n="$1" desired remove s args last_activity last_activity_epoch age
|
||||||
|
|
||||||
|
|
@ -835,7 +566,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# concurrency group in labels.yml. With a comment-only bot on the panel
|
# concurrency group in labels.yml. With a comment-only bot on the panel
|
||||||
# this path stays cold and the AUTHOR requests the human.
|
# this path stays cold and the AUTHOR requests the human.
|
||||||
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
||||||
run forge_request_reviewer "$n" "$HUMAN"
|
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
|
||||||
log "#$n: requested $HUMAN (round passed)"
|
log "#$n: requested $HUMAN (round passed)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -897,21 +628,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
||||||
args=(--add-label "$desired${add:+,$add}")
|
args=(--add-label "$desired${add:+,$add}")
|
||||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
||||||
if label_write "$n" "${args[@]}"; then
|
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
||||||
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
|
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
|
||||||
else
|
else
|
||||||
# A WRITE THAT DID NOT HAPPEN IS FATAL, not a warning (#192). This was
|
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
||||||
# `log WARNING` and fell through, so the sweep printed `reconciled.` and
|
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
|
||||||
# exited green over an edit the forge had refused — the
|
|
||||||
# degraded-write-reports-success class #188 exists to eliminate,
|
|
||||||
# surviving inside the reconciler that reports it.
|
|
||||||
#
|
|
||||||
# The old text also diagnosed a cause it had not established: it named a
|
|
||||||
# missing label and told the operator to bootstrap, when the label was
|
|
||||||
# present and the call had returned 500. #101's rule is report, do not
|
|
||||||
# diagnose — so this says what was attempted and that it did not happen,
|
|
||||||
# and leaves the backend's own stderr to say why.
|
|
||||||
return 1
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -919,8 +640,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# Drafts are exempt (the build phase is the builder's); the version
|
# Drafts are exempt (the build phase is the builder's); the version
|
||||||
# reads cost two API calls and only on PRs missing the label.
|
# reads cost two API calls and only on PRs missing the label.
|
||||||
if [ "$DRAFT" != true ] && ! has_label release; then
|
if [ "$DRAFT" != true ] && ! has_label release; then
|
||||||
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
|
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")"
|
||||||
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- merge-next: cleared, never set ----------------------------------
|
# ---- merge-next: cleared, never set ----------------------------------
|
||||||
|
|
@ -931,24 +651,18 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# the moment the PR is no longer the thing a human should merge next, the
|
# the moment the PR is no longer the thing a human should merge next, the
|
||||||
# claim is removed. Setting it stays with whoever owns the queue.
|
# claim is removed. Setting it stays with whoever owns the queue.
|
||||||
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
||||||
label_write "$n" --remove-label merge-next || return 1
|
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null
|
||||||
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
||||||
# forge_pr_activity owns the portable half: issue comments + commits +
|
|
||||||
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
|
|
||||||
# Forgejo; the forgejo backend re-derives it from reviews with
|
|
||||||
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
|
|
||||||
# stay here — they are already in hand and need no second fetch.
|
|
||||||
last_activity="$(
|
last_activity="$(
|
||||||
{
|
{
|
||||||
jq -r '.created_at' <<<"$PR_JSON"
|
jq -r '.created_at' <<<"$PR_JSON"
|
||||||
jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
|
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
||||||
# Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr —
|
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
||||||
# forge_api names failures loudly, and hiding them re-opens this issue's
|
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
||||||
# silent-green class (#4879 / #101 D5).
|
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
||||||
forge_pr_activity "$n" || true
|
|
||||||
} | sort | tail -n1
|
} | sort | tail -n1
|
||||||
)"
|
)"
|
||||||
last_activity_epoch="$(date -d "$last_activity" +%s)"
|
last_activity_epoch="$(date -d "$last_activity" +%s)"
|
||||||
|
|
@ -957,11 +671,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
|
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
|
||||||
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
|
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
|
||||||
if has_label stale; then
|
if has_label stale; then
|
||||||
label_write "$n" --remove-label stale || return 1
|
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
||||||
log "#$n: unstale"
|
log "#$n: unstale"
|
||||||
fi
|
fi
|
||||||
elif ! has_label stale; then
|
elif ! has_label stale; then
|
||||||
label_write "$n" --add-label stale || return 1
|
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
|
||||||
log "#$n: stale ($((age / 3600))h quiet)"
|
log "#$n: stale ($((age / 3600))h quiet)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -973,81 +687,43 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
if has_label needs-ruling; then
|
if has_label needs-ruling; then
|
||||||
reconcile_ruling "$n" "$last_activity_epoch" "$NOW"
|
reconcile_ruling "$n" "$last_activity_epoch" "$NOW"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# `attention` belongs on the assigned issue that owns the claim, never on
|
|
||||||
# a pull request (#232). Behind the label gate so ordinary PRs pay no read.
|
|
||||||
if has_label attention; then
|
|
||||||
reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" ""
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
# BEFORE anything reads the board (#188). Every call site below is still
|
|
||||||
# `gh`, so that is what this declares — honestly, which is the point: on
|
|
||||||
# a Forgejo consumer the preflight refuses here instead of letting the
|
|
||||||
# sweep run blind and print "reconciled." over zero PRs (rig run 979).
|
|
||||||
# The forge is decided once, here, before anything reads the board, and
|
|
||||||
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
|
||||||
# wrapper that stood here died with the call-site port: it declared "this
|
|
||||||
# code uses gh", which stopped being true the moment every site went
|
|
||||||
# through the shim, and leaving it would have defaulted the forgejo path
|
|
||||||
# into the very client its own preflight refuses.
|
|
||||||
forge_preflight || return 1
|
|
||||||
# "" means decide from the environment; forge_select takes an explicit
|
|
||||||
# forge only in tests.
|
|
||||||
forge_select "" || return 1
|
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
||||||
load_config "$LABELS_CONF"
|
load_config "$LABELS_CONF"
|
||||||
NOW="$(date +%s)"
|
NOW="$(date +%s)"
|
||||||
|
|
||||||
# The bootstrap keys on the INPUT, never the event name. It used to test
|
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
|
||||||
# GITHUB_EVENT_NAME = workflow_dispatch — correct while an operator's manual
|
log "workflow_dispatch: bootstrapping the taxonomy"
|
||||||
# dispatch was the only dispatch there was, and wrong from #209 on, when the
|
|
||||||
# trigger job made EVERY event-woken sweep a workflow_dispatch run: the
|
|
||||||
# bootstrap=no input became inert by construction, and every board event
|
|
||||||
# re-upserted the taxonomy (ceremony#215 — runs 459/523, then venue drill
|
|
||||||
# runs 16/17, which bootstrapped on a delivered "no" and caught this).
|
|
||||||
if [ "${BOOTSTRAP:-no}" = yes ]; then
|
|
||||||
log "bootstrap=yes: bootstrapping the taxonomy"
|
|
||||||
bootstrap_labels
|
bootstrap_labels
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
||||||
# add against it, because one unknown name fails the whole edit call.
|
# add against it, because one unknown name fails the whole edit call.
|
||||||
REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")"
|
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")"
|
||||||
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
||||||
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
|
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
|
||||||
|
|
||||||
local n output status total=0 unreadable=0 write_failures=0 sampled_reason=""
|
local n output status total=0 unreadable=0 sampled_reason=""
|
||||||
while IFS= read -r n; do
|
while IFS= read -r n; do
|
||||||
[ -n "$n" ] || continue
|
[ -n "$n" ] || continue
|
||||||
total=$((total + 1))
|
total=$((total + 1))
|
||||||
status=0
|
status=0
|
||||||
output="$(
|
output="$(
|
||||||
(
|
(
|
||||||
PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
|
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
||||||
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
||||||
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
|
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
|
||||||
set_required_bots "$AUTHOR"
|
set_required_bots "$AUTHOR"
|
||||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
||||||
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
||||||
MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")"
|
|
||||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
||||||
# This allow-list answers whether a row is a submitted, gradeable review;
|
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
||||||
# bot_verdict separately answers what that submitted verdict says (#235).
|
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
||||||
# PENDING drafts and Forgejo REQUEST_REVIEW request rows are not reviews.
|
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
||||||
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
| jq -s '[.[] | select(.state != "PENDING")]')"
|
||||||
| jq -s '[.[] | select(.state == "APPROVED"
|
|
||||||
or .state == "CHANGES_REQUESTED"
|
|
||||||
or .state == "REQUEST_CHANGES"
|
|
||||||
or .state == "COMMENTED"
|
|
||||||
or .state == "COMMENT")]')"
|
|
||||||
# Read AFTER the reviews: review_filter_probe captures REVIEWS_JSON at
|
|
||||||
# this boundary. The request set itself comes from the backend's exact
|
|
||||||
# live representation rather than being derived from verdicts (#238).
|
|
||||||
REQUESTED="$(forge_pr_review_requests "$n")"
|
|
||||||
# mergeability + the check rollup, the two facts the state machine was
|
# mergeability + the check rollup, the two facts the state machine was
|
||||||
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
||||||
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
||||||
|
|
@ -1062,7 +738,7 @@ main() {
|
||||||
# D2), never left to interleave raw into the per-PR output block,
|
# D2), never left to interleave raw into the per-PR output block,
|
||||||
# where an unlucky line could collide with a matched string.
|
# where an unlucky line could collide with a matched string.
|
||||||
GH_VIEW_ERR_FILE="$(mktemp)"
|
GH_VIEW_ERR_FILE="$(mktemp)"
|
||||||
GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
|
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
|
||||||
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
|
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
|
||||||
rm -f "$GH_VIEW_ERR_FILE"
|
rm -f "$GH_VIEW_ERR_FILE"
|
||||||
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
||||||
|
|
@ -1079,29 +755,6 @@ main() {
|
||||||
log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")"
|
log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# The head's own clock, for the blocker:unrequested grace (#236 D2). One
|
|
||||||
# read, pinned to the head SHA — not `gh pr view --json commits`, which
|
|
||||||
# asks for the FIRST hundred commits and would date a longer PR by a
|
|
||||||
# commit that is not its head. Last of the fetches on purpose: a PR the
|
|
||||||
# skip above walked away from must not pay for it, and neither do drafts,
|
|
||||||
# which never reach that blocker. Empty (a failed read, or a body without
|
|
||||||
# the field) leaves the blocker unjudged, by unrequested_quiescent.
|
|
||||||
HEAD_COMMIT_AT=""
|
|
||||||
if [ "$DRAFT" != true ]; then
|
|
||||||
HEAD_COMMIT_ERR_FILE="$(mktemp)"
|
|
||||||
HEAD_COMMIT_AT="$(forge_commit_at "$HEAD_SHA" \
|
|
||||||
2>"$HEAD_COMMIT_ERR_FILE" || echo "")"
|
|
||||||
HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")"
|
|
||||||
rm -f "$HEAD_COMMIT_ERR_FILE"
|
|
||||||
case "$HEAD_COMMIT_AT" in
|
|
||||||
"" | null)
|
|
||||||
# Say why it degraded (#101 D2/D4), on its own line: this one
|
|
||||||
# narrows a blocker rather than skipping the PR, so it must not
|
|
||||||
# read as the wholly-blind shape the counted line above matches.
|
|
||||||
HEAD_COMMIT_AT=""
|
|
||||||
log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
reconcile_pr "$n"
|
reconcile_pr "$n"
|
||||||
) 2>&1
|
) 2>&1
|
||||||
)" || status=$?
|
)" || status=$?
|
||||||
|
|
@ -1113,33 +766,10 @@ main() {
|
||||||
sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)"
|
sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)"
|
||||||
fi
|
fi
|
||||||
elif [ "$status" -ne 0 ]; then
|
elif [ "$status" -ne 0 ]; then
|
||||||
# The per-PR tolerance is right and stays: one bad PR must not blind the
|
log "#$n: reconcile failed — continuing with the remaining PRs"
|
||||||
# sweep over the rest of the board. What was missing is the sweep-level
|
|
||||||
# accounting — a failed WRITE has to reach main's exit code, or a builder
|
|
||||||
# satisfies every task and the sweep still prints `reconciled.` over an
|
|
||||||
# edit that never happened (#192, @kimi-reviewer-andresmgsl #5189).
|
|
||||||
#
|
|
||||||
# Reads stay tolerated: an unreadable fact is already reported by the
|
|
||||||
# blind-sweep warning and leaves the board untouched. A write is
|
|
||||||
# different — the board and the tree now disagree.
|
|
||||||
if grep -q "^labels: #$n: label edit FAILED" <<<"$output"; then
|
|
||||||
write_failures=$((write_failures + 1))
|
|
||||||
log "#$n: reconcile failed on a WRITE — continuing the sweep, but it will not report success"
|
|
||||||
else
|
|
||||||
log "#$n: reconcile failed — continuing with the remaining PRs"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done < <(forge_pr_list)
|
done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number')
|
||||||
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
|
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
|
||||||
if [ "$write_failures" -gt 0 ]; then
|
|
||||||
# The line must not contain the literal "reconciled." ANYWHERE — "NOT
|
|
||||||
# reconciled." still does, and a consumer grepping a job-log tail for that
|
|
||||||
# token would find it after a write that did not happen
|
|
||||||
# (@codex-reviewer-andresmgsl). The test asserts the whole output is free
|
|
||||||
# of it, not merely that the success prefix is absent.
|
|
||||||
log "$write_failures label write(s) attempted did not happen — sweep incomplete"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
log "reconciled."
|
log "reconciled."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ else
|
||||||
set -u
|
set -u
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
||||||
|
|
||||||
# labels-scope.sh — the additive half of the labels automation: derive
|
# labels-scope.sh — the additive half of the labels automation: derive
|
||||||
# scope:* labels from a PR's changed paths and ADD them, touching nothing
|
# scope:* labels from a PR's changed paths and ADD them, touching nothing
|
||||||
# else. This seat belonged to actions/labeler@v5 until #130: even under
|
# else. This seat belonged to actions/labeler@v5 until #130: even under
|
||||||
|
|
@ -71,14 +68,6 @@ glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
|
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
|
||||||
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
|
|
||||||
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
|
|
||||||
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
|
|
||||||
# jq 1.7 parses it, which is why this survived: GitHub's hosted
|
|
||||||
# ubuntu-latest ships 1.7, and the Forgejo runner image
|
|
||||||
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
|
|
||||||
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
|
|
||||||
# jq compile error before the config was even read.
|
|
||||||
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
|
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
|
||||||
# where an unsupported key is a loud error naming the label it sits under.
|
# where an unsupported key is a loud error naming the label it sits under.
|
||||||
yq -o=json '.' - | jq -r '
|
yq -o=json '.' - | jq -r '
|
||||||
|
|
@ -86,38 +75,38 @@ parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
|
||||||
error("labeler config: top level must be a map of label -> rules")
|
error("labeler config: top level must be a map of label -> rules")
|
||||||
else . end
|
else . end
|
||||||
| to_entries[]
|
| to_entries[]
|
||||||
| .key as $lbl
|
| .key as $label
|
||||||
| (if (.value | type) != "array" then
|
| (if (.value | type) != "array" then
|
||||||
error("labeler config: \($lbl): rules must be a list")
|
error("labeler config: \($label): rules must be a list")
|
||||||
else .value end)[]
|
else .value end)[]
|
||||||
| (if type != "object" then
|
| (if type != "object" then
|
||||||
error("labeler config: \($lbl): each rule must be a map")
|
error("labeler config: \($label): each rule must be a map")
|
||||||
else . end)
|
else . end)
|
||||||
| ((keys - ["changed-files"]) as $extra
|
| ((keys - ["changed-files"]) as $extra
|
||||||
| if ($extra | length) > 0 then
|
| if ($extra | length) > 0 then
|
||||||
error("labeler config: \($lbl): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
|
error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
|
||||||
else . end)
|
else . end)
|
||||||
| .["changed-files"]
|
| .["changed-files"]
|
||||||
| (if type == "object" then [.]
|
| (if type == "object" then [.]
|
||||||
elif type == "array" then .
|
elif type == "array" then .
|
||||||
else error("labeler config: \($lbl): changed-files must be a list") end)[]
|
else error("labeler config: \($label): changed-files must be a list") end)[]
|
||||||
| (if type != "object" then
|
| (if type != "object" then
|
||||||
error("labeler config: \($lbl): each changed-files entry must be a map")
|
error("labeler config: \($label): each changed-files entry must be a map")
|
||||||
else . end)
|
else . end)
|
||||||
| ((keys - ["any-glob-to-any-file"]) as $extra
|
| ((keys - ["any-glob-to-any-file"]) as $extra
|
||||||
| if ($extra | length) > 0 then
|
| if ($extra | length) > 0 then
|
||||||
error("labeler config: \($lbl): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
|
error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
|
||||||
else . end)
|
else . end)
|
||||||
| .["any-glob-to-any-file"]
|
| .["any-glob-to-any-file"]
|
||||||
| (if type == "string" then [.]
|
| (if type == "string" then [.]
|
||||||
elif type == "array" then .
|
elif type == "array" then .
|
||||||
else error("labeler config: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
|
else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[]
|
||||||
| (if type != "string" then
|
| (if type != "string" then
|
||||||
error("labeler config: \($lbl): globs must be strings")
|
error("labeler config: \($label): globs must be strings")
|
||||||
elif contains("\\") then
|
elif contains("\\") then
|
||||||
error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
|
error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)")
|
||||||
else . end)
|
else . end)
|
||||||
| [$lbl, .] | @tsv
|
| [$label, .] | @tsv
|
||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,21 +125,6 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
# See labels-reconcile's twin (#188). This action's degraded read was the
|
|
||||||
# quietest of the three: an unreadable mapping and an absent one produced
|
|
||||||
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
|
|
||||||
# scope labels and nothing said why.
|
|
||||||
# The forge is decided once, here, before anything reads the board, and
|
|
||||||
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
|
||||||
# wrapper that stood here died with the call-site port: it declared "this
|
|
||||||
# code uses gh", which stopped being true the moment every site went
|
|
||||||
# through the shim, and leaving it would have defaulted the forgejo path
|
|
||||||
# into the very client its own preflight refuses.
|
|
||||||
forge_preflight || return 1
|
|
||||||
# "" means decide from the environment; forge_select takes an explicit
|
|
||||||
# forge only in tests.
|
|
||||||
forge_select "" || return 1
|
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
|
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
|
||||||
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
|
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
|
||||||
|
|
@ -160,13 +134,13 @@ main() {
|
||||||
# No mapping is a consumer that has not adopted scope labels — an
|
# No mapping is a consumer that has not adopted scope labels — an
|
||||||
# advisory no-op, not a red run (scopes locate, they do not alert). A
|
# advisory no-op, not a red run (scopes locate, they do not alert). A
|
||||||
# mapping that EXISTS but does not parse still fails loudly below.
|
# mapping that EXISTS but does not parse still fails loudly below.
|
||||||
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
||||||
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
|
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
|
||||||
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
|
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
tsv="$(parse_labeler_config <<<"$config")"
|
tsv="$(parse_labeler_config <<<"$config")"
|
||||||
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
||||||
labels="$(derive_labels "$tsv" "$files")"
|
labels="$(derive_labels "$tsv" "$files")"
|
||||||
|
|
||||||
if [ -z "$labels" ]; then
|
if [ -z "$labels" ]; then
|
||||||
|
|
@ -174,8 +148,8 @@ main() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
local args=()
|
local args=()
|
||||||
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
|
while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels"
|
||||||
run forge_labels_add "$PR_NUMBER" "${args[@]}"
|
run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent
|
||||||
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
|
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
name: Refs not closing
|
|
||||||
description: >-
|
|
||||||
Refuse a pull request whose `Refs #N` promise contradicts GitHub's
|
|
||||||
closing-issue graph (#218). GitHub recognizes closing keywords anywhere
|
|
||||||
in a PR body, including ordinary prose and code spans; the action reads
|
|
||||||
the graph once and lets a pure script decide whether any Refs target is
|
|
||||||
already scheduled to close.
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: refs targets are not closing
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
run: bash "$GITHUB_ACTION_PATH/run.sh"
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# refs-not-closing.sh <body-file> [<closing-issue-number> ...] — compare the
|
|
||||||
# issues a PR promises merely to reference with GitHub's closing-issue graph
|
|
||||||
# (#218). The graph is authoritative because it includes both closing
|
|
||||||
# keywords and sidebar links. The body still matters: only an issue named by
|
|
||||||
# `Ref #N` or `Refs #N` is protected, so an ordinary `Closes #N` PR remains
|
|
||||||
# untouched.
|
|
||||||
#
|
|
||||||
# This decision stays network-free so test/refs-not-closing.test.sh can drive
|
|
||||||
# the incident matrix offline. The composite action gathers both facts in one
|
|
||||||
# GraphQL read and passes them here. A failed or partial read never reaches
|
|
||||||
# this script: action.yml refuses it before asking for a verdict.
|
|
||||||
|
|
||||||
body_file="${1:-}"
|
|
||||||
shift || true
|
|
||||||
|
|
||||||
if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then
|
|
||||||
echo "refs-not-closing: body file is missing or unreadable: ${body_file:-<none>}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -A closing=()
|
|
||||||
for issue in "$@"; do
|
|
||||||
case "$issue" in
|
|
||||||
''|*[!0-9]*)
|
|
||||||
echo "refs-not-closing: invalid closing issue number: '$issue'" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
closing["$issue"]=1
|
|
||||||
done
|
|
||||||
|
|
||||||
mapfile -t refs_targets < <(
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
rest = tolower($0)
|
|
||||||
while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*[[]?#[0-9]+/)) {
|
|
||||||
token = substr(rest, RSTART, RLENGTH)
|
|
||||||
sub(/^.*#/, "", token)
|
|
||||||
print token + 0
|
|
||||||
rest = substr(rest, RSTART + RLENGTH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$body_file" | sort -nu
|
|
||||||
)
|
|
||||||
|
|
||||||
intersections=()
|
|
||||||
for issue in "${refs_targets[@]}"; do
|
|
||||||
if [ -n "${closing[$issue]:-}" ]; then
|
|
||||||
intersections+=("$issue")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "${#intersections[@]}" -eq 0 ]; then
|
|
||||||
echo "refs-not-closing: no Refs target appears in GitHub's closing-issue graph"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
sentence_for_issue() {
|
|
||||||
local issue="$1" mode="$2"
|
|
||||||
awk -v issue="$issue" -v mode="$mode" '
|
|
||||||
/^[[:space:]]*$/ {
|
|
||||||
if (paragraph != "") {
|
|
||||||
text = text paragraph "\n\n"
|
|
||||||
paragraph = ""
|
|
||||||
}
|
|
||||||
next
|
|
||||||
}
|
|
||||||
{
|
|
||||||
if (paragraph != "") paragraph = paragraph " "
|
|
||||||
paragraph = paragraph $0
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
text = text paragraph
|
|
||||||
count = split(text, sentence, /[.!?][[:space:]]+|\n\n+/)
|
|
||||||
if (mode == "closing") {
|
|
||||||
needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
|
|
||||||
} else {
|
|
||||||
needle = "(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*\\[?#[[:space:]]*" issue "([^0-9]|$)"
|
|
||||||
}
|
|
||||||
for (i = 1; i <= count; i++) {
|
|
||||||
lower = tolower(sentence[i])
|
|
||||||
if (match(lower, needle)) {
|
|
||||||
matched = substr(sentence[i], RSTART, RLENGTH)
|
|
||||||
sub(/^[^[:alnum:]_]*/, "", matched)
|
|
||||||
sub(/[^0-9]*$/, "", matched)
|
|
||||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", sentence[i])
|
|
||||||
printf "%s\t%s\n", matched, sentence[i]
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$body_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
printf 'refs-not-closing: Refs target(s) also scheduled to close:'
|
|
||||||
printf ' #%s' "${intersections[@]}"
|
|
||||||
printf '\n'
|
|
||||||
|
|
||||||
for issue in "${intersections[@]}"; do
|
|
||||||
detail="$(sentence_for_issue "$issue" closing)"
|
|
||||||
if [ -z "$detail" ]; then
|
|
||||||
detail="$(sentence_for_issue "$issue" refs)"
|
|
||||||
printf " #%s: GitHub reports a closing reference; no adjacent closing keyword was found, so inspect the Development sidebar link.\n" "$issue"
|
|
||||||
fi
|
|
||||||
if [ -n "$detail" ]; then
|
|
||||||
matched="${detail%%$'\t'*}"
|
|
||||||
sentence="${detail#*$'\t'}"
|
|
||||||
printf ' matched: %s\n' "$matched"
|
|
||||||
printf ' sentence: %s\n' "$sentence"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
cat <<'EOF'
|
|
||||||
A `Refs #N` PR must not close N. Remove the sidebar closing link or rewrite
|
|
||||||
an adjacent closing-keyword sentence so the number comes first (`#N is
|
|
||||||
closed by hand`) or the number is omitted (`triage closes the issue by
|
|
||||||
hand`). Backticks do not protect a closing keyword from GitHub's parser.
|
|
||||||
EOF
|
|
||||||
} >&2
|
|
||||||
exit 1
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# The composite action's executable boundary (#218). Keeping the gather here
|
|
||||||
# lets the offline contract test replace the forge and prove that failed and
|
|
||||||
# partial reads cannot accidentally produce a green verdict.
|
|
||||||
#
|
|
||||||
# THE GATHER IS REST, THROUGH THE SHIM (#199). It was a single GraphQL query
|
|
||||||
# issued through `gh`, asking GitHub for `closingIssuesReferences` — its own
|
|
||||||
# parse of the closing keywords. Forgejo serves no GraphQL surface at all:
|
|
||||||
# `/api/graphql` 404s on this instance, and a real forgejo-runner job arrives
|
|
||||||
# with GITHUB_GRAPHQL_URL set to the empty string (lib/forge.sh's header).
|
|
||||||
# There was nothing to translate it to, so it is re-expressed — exactly as
|
|
||||||
# #188 re-expressed its own two GraphQL sites — over two reads both backends
|
|
||||||
# already serve, plus a parser this repo owns.
|
|
||||||
#
|
|
||||||
# WHAT THE GRAPH GAVE THAT TWO READS MUST REPLACE. This file used to call the
|
|
||||||
# graph "authoritative because it includes both closing keywords and sidebar
|
|
||||||
# links". Those two halves resolve differently here:
|
|
||||||
#
|
|
||||||
# sidebar links Forgejo has no such concept — an issue is closed by a
|
|
||||||
# keyword, never by a manual link. Nothing is lost.
|
|
||||||
# commit messages Forgejo DOES honour closing keywords in commit messages.
|
|
||||||
# A body-only parse would miss a PR that closes an issue
|
|
||||||
# from a commit subject and let through exactly the
|
|
||||||
# contradiction this action exists to catch.
|
|
||||||
#
|
|
||||||
# Hence both reads, unioned. The commit half is not optional.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
||||||
# shellcheck source=lib/issue_references.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.sh"
|
|
||||||
# shellcheck source=lib/closes_references.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
|
||||||
|
|
||||||
# No CEREMONY_FORGE_CLIENT declaration any more (#199 removes #198's): this
|
|
||||||
# file speaks the shim, not a client. Fail CLOSED at the action boundary all
|
|
||||||
# the same — "this action cannot produce a verdict" is the ACTION's contract
|
|
||||||
# and stays a refusal, while "this check should not block the board" is the
|
|
||||||
# CALLER's decision (@codex-reviewer-andresmgsl, #198).
|
|
||||||
forge_preflight || exit 1
|
|
||||||
forge_select "" || exit 1
|
|
||||||
|
|
||||||
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
|
|
||||||
[ -n "$REPO" ] || {
|
|
||||||
echo "refs-not-closing: set REPO or GITHUB_REPOSITORY to owner/name" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[ -n "${PR_NUMBER:-}" ] || {
|
|
||||||
echo "refs-not-closing: pull request number is unavailable" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
body_file="$(mktemp)"
|
|
||||||
closing_file="$(mktemp)"
|
|
||||||
trap 'rm -f "$body_file" "$closing_file"' EXIT
|
|
||||||
|
|
||||||
# A read that fails must never reach the parser: an empty body parses to an
|
|
||||||
# empty closing set, which is a PASSING verdict this action never earned.
|
|
||||||
# `set -e` covers the assignment, and the explicit checks below name which
|
|
||||||
# read failed rather than leaving the operator to guess.
|
|
||||||
if ! forge_api "repos/$REPO/pulls/$PR_NUMBER" --jq '.body // ""' >"$body_file"; then
|
|
||||||
echo "refs-not-closing: could not read PR $PR_NUMBER's body — refusing a verdict" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --paginate carries the completeness proof: the forgejo backend walks pages
|
|
||||||
# and then compares what it collected against the server's declared
|
|
||||||
# x-total-count, refusing a short gather (#188, #4699). That IS this action's
|
|
||||||
# `hasNextPage` refusal, relocated rather than reinvented — upstream refused
|
|
||||||
# past 100 closing references rather than issue a partial verdict, and an
|
|
||||||
# incomplete commit read is the same failure wearing REST's clothes.
|
|
||||||
commits_file="$(mktemp)"
|
|
||||||
trap 'rm -f "$body_file" "$closing_file" "$commits_file"' EXIT
|
|
||||||
if ! forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \
|
|
||||||
--jq '.[].commit.message' >"$commits_file"; then
|
|
||||||
echo "refs-not-closing: could not read PR $PR_NUMBER's commits completely — refusing a partial verdict" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The union. closes_references is line-oriented, so concatenating the body and
|
|
||||||
# every commit message and parsing once IS the union of parsing each — and it
|
|
||||||
# keeps one parse to reason about instead of two that could drift.
|
|
||||||
cat "$body_file" "$commits_file" | closes_references >"$closing_file"
|
|
||||||
|
|
||||||
mapfile -t closing_issues <"$closing_file"
|
|
||||||
bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \
|
|
||||||
"$body_file" "${closing_issues[@]}"
|
|
||||||
|
|
@ -62,15 +62,12 @@ done
|
||||||
|
|
||||||
# Every entry in the directory must be a publishable fragment. A stray file
|
# Every entry in the directory must be a publishable fragment. A stray file
|
||||||
# in a machine-assembled directory is a mistake to surface, never to skip —
|
# in a machine-assembled directory is a mistake to surface, never to skip —
|
||||||
# except README.md, the directory's marker (#112 D1), and 'shape', the
|
# except README.md, the directory's marker (#112 D1). This runs before the
|
||||||
# declared anchor (#182), which changelog_shape_problem validates below and
|
# zero-fragments check so a directory holding only 'notes.txt' names the
|
||||||
# which deliberately survives the consumption: it is the declaration a
|
# stray file instead of claiming emptiness.
|
||||||
# reader in the directory finds, and it must still be there after the
|
|
||||||
# release empties the fragments out.
|
|
||||||
for f in "$dir"/*; do
|
for f in "$dir"/*; do
|
||||||
[ -e "$f" ] || continue
|
[ -e "$f" ] || continue
|
||||||
[ "${f##*/}" = "README.md" ] && continue
|
[ "${f##*/}" = "README.md" ] && continue
|
||||||
[ "${f##*/}" = "shape" ] && continue
|
|
||||||
if ! diagnosis="$(changelog_fragment_problem "$f")"; then
|
if ! diagnosis="$(changelog_fragment_problem "$f")"; then
|
||||||
refuse "$diagnosis"
|
refuse "$diagnosis"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The upstream deferral record now names its floor and a dated measurement instead of a frozen `0.7.4` ceiling that expired before it was written (#269).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `docs/UPSTREAM-SYNC.md` now records unconditionally that the next sync campaign merges rather than ports, and so advances the ancestry baseline (#269).
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).
|
|
||||||
|
|
@ -7,5 +7,4 @@ published, nothing else — and the release PR folds them all into the next
|
||||||
filenames never conflict, which is this directory's whole reason to exist.
|
filenames never conflict, which is this directory's whole reason to exist.
|
||||||
This README is the marker that keeps the directory tracked when it holds no
|
This README is the marker that keeps the directory tracked when it holds no
|
||||||
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
|
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
|
||||||
delete it. The `shape` sentinel beside it declares the set's shape —
|
delete it.
|
||||||
`grouped` here, so every fragment carries `### ` headings (#182).
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
grouped
|
|
||||||
|
|
@ -27,7 +27,7 @@ edits to this guide (#12).
|
||||||
- **The `release` label must exist** before the first ceremony PR — it is
|
- **The `release` label must exist** before the first ceremony PR — it is
|
||||||
the merge door's declared-intent read
|
the merge door's declared-intent read
|
||||||
([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels
|
([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels
|
||||||
sweep caller's `workflow_dispatch`
|
workflow's `workflow_dispatch`
|
||||||
([Labels automation](#labels-automation)), or create it by hand,
|
([Labels automation](#labels-automation)), or create it by hand,
|
||||||
matching the core table
|
matching the core table
|
||||||
([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)):
|
([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)):
|
||||||
|
|
@ -60,10 +60,10 @@ the machinery at all:
|
||||||
the release PR assembles the section
|
the release PR assembles the section
|
||||||
([Assembling a release section](#assembling-a-release-section)).
|
([Assembling a release section](#assembling-a-release-section)).
|
||||||
|
|
||||||
Fragment mode is available at `0.2.0` and later, and not in `0.1.0`.
|
Fragment mode is **unreleased** and not in `0.1.0`. A consumer pinned
|
||||||
A consumer pinned to `0.1.0` bootstraps the legacy shape instead — the
|
to `0.1.0` bootstraps the legacy shape instead — the preamble plus an
|
||||||
preamble plus an empty `## Unreleased` section for entries to land
|
empty `## Unreleased` section for entries to land under — and converts
|
||||||
under — and converts on the pin bump to `0.2.0` or later; never mix
|
on the pin bump to the first tag carrying fragment mode; never mix
|
||||||
refs to adopt it early.
|
refs to adopt it early.
|
||||||
3. **`drills/README.md`** defining what a drill *means* in this repo —
|
3. **`drills/README.md`** defining what a drill *means* in this repo —
|
||||||
each repo names its own
|
each repo names its own
|
||||||
|
|
@ -85,16 +85,14 @@ the machinery at all:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
|
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
|
||||||
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
|
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
|
||||||
# changelog-assembled is available at 0.2.0 and later, not in
|
# Unreleased: changelog-assembled is not in 0.1.0. Adopt this step
|
||||||
# 0.1.0. Adopt this step with the pin bump to 0.2.0 or later;
|
# with the pin bump to the first tag that carries it; never mix
|
||||||
# never mix refs. Green NOTICE on every non-release PR; on a
|
# refs. Green NOTICE on every non-release PR; on a release PR it
|
||||||
# release PR it asserts the stamped section is exactly the
|
# asserts the stamped section is exactly the fragments it consumed.
|
||||||
# fragments it consumed.
|
|
||||||
- uses: heavy-duty/ceremony/actions/changelog-assembled@<pinned-tag>
|
- uses: heavy-duty/ceremony/actions/changelog-assembled@<pinned-tag>
|
||||||
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
|
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
|
||||||
# runner-isolated is available at 0.2.0 and later, not in 0.1.0.
|
# Unreleased: runner-isolated is not in 0.1.0. Adopt this step with
|
||||||
# Adopt this step with the pin bump to 0.2.0 or later; never mix
|
# the pin bump to the first tag that carries it; never mix refs.
|
||||||
# refs.
|
|
||||||
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
|
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -113,59 +111,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`. A marker is the literal token
|
This guide documents `main`. New machinery is marked **unreleased**
|
||||||
`**unreleased**` immediately followed by its issue citation (for example,
|
here until a release tag ships it. If an action does not exist at the
|
||||||
`(#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 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
|
|
||||||
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`,
|
||||||
`changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not
|
`changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not
|
||||||
`changelog-assembled` or `runner-isolated`.
|
`changelog-assembled` or `runner-isolated`.
|
||||||
6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own
|
6. **Labels automation** (optional but recommended): the caller from
|
||||||
caller because `edited` is load-bearing: #200 gained its accidental
|
[Labels automation](#labels-automation), plus `.github/labels.conf`
|
||||||
closing keyword after the PR opened, with no push to wake ordinary CI.
|
|
||||||
It costs the consumer one read-only workflow file and no other machinery:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Refs guard
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, edited, reopened, synchronize]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
refs-not-closing:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
- uses: heavy-duty/ceremony/actions/refs-not-closing@<pinned-tag>
|
|
||||||
```
|
|
||||||
|
|
||||||
`refs-not-closing` is available at `0.6.0` and later (#218). 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
|
|
||||||
[Labels automation](#labels-automation) — the event-facing labels
|
|
||||||
caller and the sweep caller (#209) — plus `.github/labels.conf`
|
|
||||||
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
|
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
|
||||||
path→scope globs). Run the sweep caller's `workflow_dispatch` once —
|
path→scope globs). Run `workflow_dispatch` once — **this bootstraps
|
||||||
**this bootstraps the taxonomy, `release` label included** — and use it
|
the taxonomy, `release` label included**.
|
||||||
again whenever an operator needs a full-board sweep immediately.
|
7. **The artifact hook** (optional): `.github/actions/release-artifact/`
|
||||||
8. **The artifact hook** (optional): `.github/actions/release-artifact/`
|
|
||||||
per [The artifact hook](#the-artifact-hook). No hook → the source
|
per [The artifact hook](#the-artifact-hook). No hook → the source
|
||||||
tarball is the package.
|
tarball is the package.
|
||||||
|
|
||||||
|
|
@ -190,11 +148,8 @@ precisely so the machinery is safe to work on
|
||||||
sibling `push:` silently kills a door (rig's review catch).
|
sibling `push:` silently kills a door (rig's review catch).
|
||||||
- [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in
|
- [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in
|
||||||
the bootstrap list above (with `fetch-depth: 0` on the checkout).
|
the bootstrap list above (with `fetch-depth: 0` on the checkout).
|
||||||
- [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony
|
|
||||||
pin as the release caller and CI guard steps.
|
|
||||||
- [ ] Replace `labels.yml` with the caller from
|
- [ ] Replace `labels.yml` with the caller from
|
||||||
[Labels automation](#labels-automation) and add the sweep caller
|
[Labels automation](#labels-automation); extract
|
||||||
`labels-sweep.yml` beside it (#209); extract
|
|
||||||
`.github/labels.conf` from the old reconciler's embedded config —
|
`.github/labels.conf` from the old reconciler's embedded config —
|
||||||
the `panel=` roster line and the repo's `scope:*` rows
|
the `panel=` roster line and the repo's `scope:*` rows
|
||||||
([the format](#labels-automation)). `.github/labeler.yml` stays as
|
([the format](#labels-automation)). `.github/labeler.yml` stays as
|
||||||
|
|
@ -291,9 +246,8 @@ tag door instead (the known first-release edge, cast#111).
|
||||||
### The artifact hook
|
### The artifact hook
|
||||||
|
|
||||||
If the repository contains `.github/actions/release-artifact/action.yml`,
|
If the repository contains `.github/actions/release-artifact/action.yml`,
|
||||||
both doors invoke it — after the tag exists, before the release is
|
both doors invoke it — after the tag exists, before `gh release create` —
|
||||||
published — with the release `version` as input and `RELEASE_ASSETS_DIR`
|
with the release `version` as input and `RELEASE_ASSETS_DIR` exported.
|
||||||
exported.
|
|
||||||
Contract for hook authors:
|
Contract for hook authors:
|
||||||
|
|
||||||
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
|
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
|
||||||
|
|
@ -304,28 +258,9 @@ Contract for hook authors:
|
||||||
|
|
||||||
A failed hook leaves the tag created but no release published. Recovery is
|
A failed hook leaves the tag created but no release published. Recovery is
|
||||||
the tag door's semantics: fix the cause, then delete and re-push the same
|
the tag door's semantics: fix the cause, then delete and re-push the same
|
||||||
tag — the tag door publishes for it. That path is forge-neutral and is the
|
tag (the tag door publishes for it), or run `gh release create` by hand from
|
||||||
one to prefer.
|
a fixed tree. The merge door's nothing-exists assert will refuse a re-run of
|
||||||
|
the completed merge, by design.
|
||||||
If you must publish by hand instead, use whatever your forge provides;
|
|
||||||
ceremony itself no longer names a client here, because on a Forgejo runner
|
|
||||||
there is no `gh` to name (#191):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# GitHub
|
|
||||||
gh release create "$VER" --verify-tag --title "$VER" \
|
|
||||||
--notes-file notes.md -R "$OWNER/$REPO"
|
|
||||||
|
|
||||||
# Forgejo / Gitea — POST /repos/{owner}/{repo}/releases
|
|
||||||
curl -sS -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -nc --arg t "$VER" --rawfile b notes.md \
|
|
||||||
'{tag_name:$t,name:$t,body:$b}')" \
|
|
||||||
"$FORGE/api/v1/repos/$OWNER/$REPO/releases"
|
|
||||||
```
|
|
||||||
|
|
||||||
The merge door's nothing-exists assert will refuse a re-run of the
|
|
||||||
completed merge, by design.
|
|
||||||
|
|
||||||
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
|
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
|
||||||
tag IS the package. Worked examples land with the conversions: cast's tgz
|
tag IS the package. Worked examples land with the conversions: cast's tgz
|
||||||
|
|
@ -333,43 +268,15 @@ build (#15) and incubator's GHCR image push (#16).
|
||||||
|
|
||||||
## Labels automation
|
## Labels automation
|
||||||
|
|
||||||
The labels automation is two reusable workflows since #209, adopted
|
The reusable labels workflow owns two independent jobs: additive path-based
|
||||||
together at the same pin:
|
`scope:*` labels and reconciliation of PR state, blockers, handoff, stale
|
||||||
|
status, and the `needs-ruling` invariants on both surfaces — the bare-flag
|
||||||
|
check and the 7-day comment-only nudge (#52; the sweep reads that flag and
|
||||||
|
never writes it). The consumer keeps its path mapping in
|
||||||
|
`.github/labeler.yml` and its review panel plus scope taxonomy in
|
||||||
|
`.github/labels.conf`.
|
||||||
|
|
||||||
- **`labels.yml`** — the event-facing half, called on PR and issue events.
|
**Additive means additive** (unreleased — #130): the scope job's only label
|
||||||
Same-repository PRs keep two write-capable jobs: additive path-based
|
|
||||||
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
|
|
||||||
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
|
|
||||||
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
|
|
||||||
plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
|
||||||
one of the two documented exemptions from the token's no-retrigger rule,
|
|
||||||
so no PAT anywhere in the path and no loop: the sweep dispatches
|
|
||||||
nothing). On this Forgejo a fork-headed `pull_request_target` token is
|
|
||||||
read-only, so those two jobs do not run. A successful `fork_head` job names
|
|
||||||
the disposition: the scheduled sweep later reconciles state, blockers, and
|
|
||||||
handoff, while path-derived `scope:*` labels are not applied to fork heads.
|
|
||||||
Apply those scope labels manually when an outside contribution needs them.
|
|
||||||
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
|
|
||||||
handoff, stale status, the issue work queue, and the `needs-ruling`
|
|
||||||
invariants on both surfaces — the bare-flag check and the 7-day
|
|
||||||
comment-only nudge (#52; the sweep reads that flag and never writes it).
|
|
||||||
Detached from PR-triggered runs on purpose: all sweeps serialize through
|
|
||||||
one shared concurrency group, and GitHub records every queue-displaced
|
|
||||||
run as CANCELLED — harmless (the surviving sweep does its work) until it
|
|
||||||
rode a `pull_request_target` run and the ❌ landed on that PR's checks
|
|
||||||
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
|
|
||||||
and its `--failed`/`--job` forms all decline a queue-displaced run).
|
|
||||||
Behind its own caller, a displaced sweep cancels on the
|
|
||||||
Actions tab, attached to no PR. Same-repository PR checks show `scope` and
|
|
||||||
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
|
|
||||||
and wait for scheduled state, blocker, and handoff reconciliation. The sweep
|
|
||||||
does not supply their path-derived scope labels.
|
|
||||||
|
|
||||||
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
|
||||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
|
||||||
|
|
||||||
**Additive means additive** (available at `0.3.0` and later — #130): the
|
|
||||||
scope job's only label
|
|
||||||
write is `POST /issues/{n}/labels`, which adds the derived scopes and removes
|
write is `POST /issues/{n}/labels`, which adds the derived scopes and removes
|
||||||
nothing, so a label applied while the job runs survives it. Earlier tags used
|
nothing, so a label applied while the job runs survives it. Earlier tags used
|
||||||
`actions/labeler@v5`, which — even under `sync-labels: false` — replaces the
|
`actions/labeler@v5`, which — even under `sync-labels: false` — replaces the
|
||||||
|
|
@ -384,203 +291,67 @@ half-honoured. The reconcile sweep also warns (never sets) when a non-draft
|
||||||
PR carries a bare `X.Y.Z` version differing from its base but no `release`
|
PR carries a bare `X.Y.Z` version differing from its base but no `release`
|
||||||
label — the merge door would refuse that merge, and the sweep says so first.
|
label — the merge door would refuse that merge, and the sweep says so first.
|
||||||
|
|
||||||
The complete event-facing caller is:
|
The complete caller is:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: labels
|
name: labels
|
||||||
on:
|
on:
|
||||||
|
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||||
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
# These carry the head/draft/review facts state:* derives from. Same-repo
|
# review_requested/review_request_removed are unreleased — not in 0.2.0;
|
||||||
# heads take the instant write + sweep-dispatch path; this Forgejo gives
|
# add them with the pin bump to the first tag carrying ceremony#137. They
|
||||||
# fork heads a read-only token, so state, blocker, and handoff reconciliation
|
# wake the sweep that clears blocker:unrequested when the panel is asked.
|
||||||
# waits for the scheduled sweep; path-derived scope labels require a manual
|
|
||||||
# write when wanted.
|
|
||||||
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
|
|
||||||
# on every push. review_requested/review_request_removed shipped in 0.3.0
|
|
||||||
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
|
|
||||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||||
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
||||||
# consumer pinned to 0.1.0 omits this block.
|
# consumer pinned to 0.1.0 omits this block.
|
||||||
issues:
|
issues:
|
||||||
# Narrowed (#199) to the actions carrying a queue-state change the hourly
|
# edited/reopened are unreleased — not in 0.2.0; add them with the pin
|
||||||
# cron cannot wait one cadence for: opened → the mint→needs-triage check,
|
# bump to the first tag carrying ceremony#144, the same bump as the
|
||||||
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the
|
# pull_request_target additions above.
|
||||||
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue
|
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
|
||||||
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
|
|
||||||
# validation + the 48h claim clock, caught within one cadence, and
|
|
||||||
# labeled/unlabeled were the issues-churn source. The handoff wake is
|
|
||||||
# pull_request_target:labeled, not issues, so this leaves it intact.
|
|
||||||
types: [opened, closed, edited, reopened]
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
checks: read # mergeability/check-rollup read for PR state
|
checks: read # mergeability/check-rollup read for PR state
|
||||||
statuses: read # commit-status rollup read for PR state
|
statuses: read # commit-status rollup read for PR state
|
||||||
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
|
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
jobs:
|
jobs:
|
||||||
labels:
|
labels:
|
||||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
||||||
# If the sweep caller below is named anything but labels-sweep.yml,
|
|
||||||
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
|
|
||||||
# dogfood does (self-labels-sweep.yml).
|
|
||||||
```
|
|
||||||
|
|
||||||
And the complete sweep caller, `labels-sweep.yml` beside it — the hourly
|
|
||||||
cron lives HERE since #209, not on the labels caller:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: labels-sweep
|
|
||||||
on:
|
|
||||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
|
||||||
# when no other engine drives board state: the cron is then the sweep's only
|
|
||||||
# wake for a review verdict landing (the labels caller has no
|
|
||||||
# pull_request_review trigger), blocker:ci-red
|
|
||||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
|
||||||
# time-based stale / 48h claim-reclaim, plus every state, blocker, and handoff
|
|
||||||
# transition for a fork-headed PR on this Forgejo. The sweep never applies
|
|
||||||
# path-derived scope labels. Issue events and same-repo PR events carry the
|
|
||||||
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
|
|
||||||
# latency on the scheduled classes while cutting nominal
|
|
||||||
# sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
|
|
||||||
# the cron: it is their discovery path. If another engine writes some of
|
|
||||||
# those transitions, only the classes with no other writer bound the cadence;
|
|
||||||
# relax it only as that list shrinks.
|
|
||||||
schedule: [{cron: "0 * * * *"}]
|
|
||||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
|
||||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
|
||||||
# wakes this workflow with bootstrap=no on every issue and same-repo PR
|
|
||||||
# event, so the declared input is part of the contract: a dispatch naming an
|
|
||||||
# undeclared input is refused, and the trigger job goes loudly red.
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
bootstrap:
|
|
||||||
description: Bootstrap the label taxonomy before sweeping
|
|
||||||
type: choice
|
|
||||||
options: ["yes", "no"]
|
|
||||||
default: "yes"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
checks: read # mergeability/check-rollup read for PR state
|
|
||||||
statuses: read # commit-status rollup read for PR state
|
|
||||||
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
jobs:
|
|
||||||
sweep:
|
|
||||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
|
|
||||||
with:
|
|
||||||
# Pass the dispatch input through the workflow_call boundary
|
|
||||||
# explicitly — a called workflow must not rely on reading the caller's
|
|
||||||
# event inputs (ceremony#215 measured that failing). Empty (schedule)
|
|
||||||
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
|
|
||||||
# taxonomy.
|
|
||||||
bootstrap: ${{ inputs.bootstrap || 'no' }}
|
|
||||||
# If this repo's PR-facing labels caller is named anything but `labels`,
|
|
||||||
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
|
|
||||||
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
|
|
||||||
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
|
||||||
# the caller", which no PR edit can do (#208 reads it).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Naming any permission sets every unnamed permission to `none`. Public
|
Naming any permission sets every unnamed permission to `none`. Public
|
||||||
repositories allow check data to be read regardless, but a private consumer
|
repositories allow check data to be read regardless, but a private consumer
|
||||||
needs the explicit reads above; without them the failure appears as an empty
|
needs both explicit reads above; without them the failure appears as an empty
|
||||||
`state:*` axis on the board rather than a red workflow run. The labels
|
`state:*` axis on the board rather than a red workflow run.
|
||||||
caller's `actions: write` is different — it is required everywhere, public
|
|
||||||
repos included: the trigger job's dispatch is a write. Without it, issue and
|
|
||||||
same-repository PR event runs go red at the trigger. Fork-headed PR runs do
|
|
||||||
not enter that write path on this Forgejo; they remain green and depend on a
|
|
||||||
healthy scheduled sweep for state, blocker, and handoff reconciliation. That
|
|
||||||
sweep does not apply their path-derived scope labels.
|
|
||||||
|
|
||||||
**The failure mode to know before bumping**: a consumer that bumps its pin
|
|
||||||
to a #209-carrying tag without adding the sweep caller gets a loud red trigger
|
|
||||||
on every issue and same-repository PR event (workflow-not-found; likewise on a
|
|
||||||
sweep caller missing its `bootstrap` input, or a labels caller missing
|
|
||||||
`actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
|
|
||||||
green, so their correctness is proven by the sweep caller's presence and its
|
|
||||||
latest scheduled run, not by the PR check alone. Never read a green
|
|
||||||
`fork_head` disposition as evidence that the scheduled sweep exists. Make the
|
|
||||||
adoption one atomic PR — pin bump, sweep caller file, and `actions: write` line
|
|
||||||
together.
|
|
||||||
|
|
||||||
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
|
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
|
||||||
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
|
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
|
||||||
it only by bumping every ceremony reference to `0.2.0` or later; never mix
|
it only by bumping every ceremony reference to `0.2.0` or later; never mix
|
||||||
refs to adopt it early. The type list has grown then narrowed across tags:
|
refs to adopt it early. At `0.2.0` the type list stops at `closed`: `edited`
|
||||||
`0.2.0` (ceremony#32) shipped `[opened, labeled, unlabeled, assigned,
|
and `reopened` are newer, part of the pending stub edit below.
|
||||||
unassigned, closed]`; `0.3.0` (ceremony#144) added `edited` and `reopened`;
|
|
||||||
ceremony#199 narrows it to `[opened, closed, edited, reopened]` and relaxes the
|
|
||||||
cron to hourly, so a consumer picks up the smaller trigger surface at the pin
|
|
||||||
bump to the first tag carrying ceremony#199. The narrowing drops
|
|
||||||
`labeled`/`unlabeled`/`assigned`/`unassigned` — validation and the 48h claim
|
|
||||||
clock, which the hourly cron catches within one cadence, and `labeled`/
|
|
||||||
`unlabeled` were the issues-churn source — while **keeping** #144's `edited`/
|
|
||||||
`reopened`: those carry a queue-state change an event uniquely carries (a body
|
|
||||||
rewrite of `Blocked by #N`, and a closed issue re-entering the queue), so the
|
|
||||||
must-fail in ceremony#199 keeps them on events. `opened` drives the
|
|
||||||
mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal;
|
|
||||||
the stub and ceremony's own caller stay byte-for-byte identical, the parity
|
|
||||||
#144 established.
|
|
||||||
|
|
||||||
The two-caller split (ceremony#209) is available at `0.4.1` and later. A
|
`pull_request_target` is intentional: fork PRs need the base repository's
|
||||||
consumer pinned to `0.4.0` or earlier keeps the previous single-caller
|
token to write labels. The reusable workflow executes no PR code. It checks
|
||||||
shape — the labels caller carrying the cron, `workflow_dispatch`, and
|
|
||||||
`actions: read` — and adopts the split at the pin bump to `0.4.1` or
|
|
||||||
later. Never mix refs to adopt it early.
|
|
||||||
|
|
||||||
The migration is **one atomic PR** with exactly four edits — crew, the
|
|
||||||
consumer whose displaced-check evidence drove #209 (crew#227, crew#250),
|
|
||||||
is the worked example; written here against `0.4.1`, the first tag
|
|
||||||
carrying the split:
|
|
||||||
|
|
||||||
1. **Pin bump, every reference together** ([Version pinning](#version-pinning)):
|
|
||||||
`0.4.0` → `0.4.1` in the labels caller's `uses:` line **and in every
|
|
||||||
other ceremony `uses:` in the repo** — crew also pins in
|
|
||||||
`release.yml` and its `ci.yml` guard steps. A repo on the doctrine
|
|
||||||
mirror re-runs `docs-sync --fix` in the same PR.
|
|
||||||
2. **New file `.github/workflows/labels-sweep.yml`** — the sweep caller
|
|
||||||
stub above, verbatim, `bootstrap` input included (the trigger's
|
|
||||||
`-f bootstrap=no` dispatch is refused if the input is undeclared).
|
|
||||||
3. **The hourly cron RELOCATES — it is moved, never copied.** Delete the
|
|
||||||
`schedule:` block (and the bare `workflow_dispatch:`) from the labels
|
|
||||||
caller in the same edit that adds the sweep caller.
|
|
||||||
**Warning**: a consumer that copies the sweep caller and leaves the
|
|
||||||
old schedule on the labels caller gets DOUBLE sweeps — every cron tick
|
|
||||||
fires both callers into the one shared `labels-reconcile` group — so
|
|
||||||
displacement goes **up**, and the fix reads as the bug getting worse.
|
|
||||||
4. **`actions: write` on the labels caller** — consumers carry
|
|
||||||
`actions: read` today (crew does); the trigger job's dispatch is a
|
|
||||||
write. The sweep caller keeps `actions: read`.
|
|
||||||
|
|
||||||
Bump without the sweep caller and the trigger job goes red on every issue and
|
|
||||||
same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
|
|
||||||
and handoff reconciliation only from the scheduled sweep, and never receive
|
|
||||||
path-derived scope labels automatically; apply those manually when wanted.
|
|
||||||
Never split these four edits across PRs.
|
|
||||||
|
|
||||||
`pull_request_target` is intentional: same-repository PRs keep the base
|
|
||||||
repository's write token without executing PR code. This Forgejo still gives
|
|
||||||
fork-headed `_target` runs a read-only token, so they attempt no writes. The
|
|
||||||
scheduled sweep later reconciles state, blockers, and handoff; it does not
|
|
||||||
apply path-derived scope labels to those heads. The reusable workflows check
|
|
||||||
out only the consumer's base branch and the pinned ceremony implementation.
|
out only the consumer's base branch and the pinned ceremony implementation.
|
||||||
The #52 ruling invariants ride exactly these triggers — but the caller above
|
The #52 ruling invariants ride exactly these triggers — but the caller above
|
||||||
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
||||||
a bare pin bump. `review_requested` and `review_request_removed` on
|
a bare pin bump. The pending edits are `review_requested` and
|
||||||
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
|
`review_request_removed` on `pull_request_target:` (#137) — the wake that
|
||||||
`blocker:unrequested` the moment the panel is asked on a same-repository head;
|
clears `blocker:unrequested` the moment the panel is asked, without which a
|
||||||
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
|
quiet repo wears that flag until the advisory cron — and `edited` and
|
||||||
events up by pinning `0.3.0` or later, never through mixed refs.
|
`reopened` on `issues:` (#144): the wakes for a body edit rewriting the
|
||||||
|
`Blocked by #N` declaration the reconcile sweep parses, and for a closed
|
||||||
|
issue re-entering the queue wearing labels derived when it closed. Make both
|
||||||
|
edits together, with the pin bump to the first tag carrying ceremony#137 and
|
||||||
|
ceremony#144 — never before it and never through mixed refs.
|
||||||
|
|
||||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||||
`triage-actors` setting, zero or more optional per-author panel rows, and
|
`triage-actors` setting, and then zero or more scope rows:
|
||||||
then zero or more scope rows:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
panel=claude-bot example-codex-bot example-grok-bot
|
panel=claude-bot example-codex-bot example-grok-bot
|
||||||
panel[example-builder]=example-codex-bot example-grok-bot
|
|
||||||
triage-actors=example-triage-bot
|
triage-actors=example-triage-bot
|
||||||
scope:cli|C5DEF5|The command-line surface
|
scope:cli|C5DEF5|The command-line surface
|
||||||
scope:docs|C5DEF5|Documentation
|
scope:docs|C5DEF5|Documentation
|
||||||
|
|
@ -592,115 +363,36 @@ rows only; adding `triage-actors=` is a parse failure, not an ignored setting.
|
||||||
Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later —
|
Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later —
|
||||||
never before it and never through mixed refs.
|
never before it and never through mixed refs.
|
||||||
|
|
||||||
The optional `panel[<login>]=` rows are available at `0.5.0` and later (#224). A row names
|
|
||||||
the effective panel for PRs authored by exactly that login — the reconciler
|
|
||||||
computes that PR's required set from the row, minus the author as always —
|
|
||||||
and every other author keeps the base `panel=`, which stays mandatory. The
|
|
||||||
panel is configured or it is the base one: ceremony never infers a reviewer
|
|
||||||
set from the model behind a login. On any earlier pin a bracketed row is a
|
|
||||||
**parse failure, not an ignored setting** — the same shape `triage-actors=`
|
|
||||||
bought at `0.2.0`, but harsher in practice: the reconcile job dies on every
|
|
||||||
PR event and every sweep until the row is removed, so the whole label board
|
|
||||||
goes down. Add the row only at or after the pin bump that carries it, never
|
|
||||||
before it and never through mixed refs.
|
|
||||||
|
|
||||||
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
||||||
allowed to mint work issues without the sweep applying `needs-triage`. Label rows use exactly
|
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
||||||
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
||||||
|
|
||||||
**Every account in `panel=` must be able to read the repository.** Requesting a
|
|
||||||
review from someone without read access is refused by the forge, not silently
|
|
||||||
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
|
|
||||||
(#188). On a public repo this is satisfied already; on a **private** consumer
|
|
||||||
it is a real failure mode when a panel member is not on the collaborator
|
|
||||||
list, and the sweep will report it rather than sweep blind.
|
|
||||||
There are no comment lines: every non-blank line must be the `panel=`
|
There are no comment lines: every non-blank line must be the `panel=`
|
||||||
setting, a `panel[<login>]=` row, the `triage-actors=` setting, or a label
|
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
||||||
row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's
|
is a parse failure, not a comment (rig #13's conversion found this the hard
|
||||||
conversion found this the hard way — keep the file data only).
|
way — keep the file data only).
|
||||||
Core state, blocker, work-queue, and release labels come from ceremony. Scope
|
Core state, blocker, work-queue, and release labels come from ceremony. Scope
|
||||||
rows remain consumer-owned because paths and surfaces differ by repository.
|
rows remain consumer-owned because paths and surfaces differ by repository.
|
||||||
|
|
||||||
After adding the callers and configuration, dispatch the sweep caller once
|
After adding the caller and configuration, run `workflow_dispatch` once to
|
||||||
to bootstrap labels on a fresh repository. A bare dispatch is also the
|
bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only
|
||||||
operator's general manual full-board sweep — the answer when the board
|
reconcile; they do not repeatedly upsert the taxonomy. When a ceremony pin
|
||||||
looks wrong now rather than after the next scheduled cadence:
|
bump adds a core label, bump the pin first and then re-dispatch
|
||||||
|
`workflow_dispatch`; the scheduled sweep warns when the pinned taxonomy
|
||||||
On GitHub, with the `gh` CLI:
|
declares a core label the repository lacks.
|
||||||
|
|
||||||
```sh
|
|
||||||
gh workflow run labels-sweep.yml -R <owner>/<repo>
|
|
||||||
```
|
|
||||||
|
|
||||||
On any forge — including Forgejo, whose runners carry no `gh` — the same
|
|
||||||
dispatch over REST, which is what the trigger job itself sends (#205):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
||||||
-d '{"ref":"main","inputs":{"bootstrap":"yes"}}' \
|
|
||||||
"$API/repos/<owner>/<repo>/actions/workflows/labels-sweep.yml/dispatches"
|
|
||||||
```
|
|
||||||
|
|
||||||
`$API` is the forge's API root — `https://api.github.com` on GitHub,
|
|
||||||
`<instance>/api/v1` on Forgejo — and success is `204` with an empty body.
|
|
||||||
|
|
||||||
Ceremony dogfoods the callers under the filenames `self-labels.yml` and
|
|
||||||
`self-labels-sweep.yml`, so the equivalent command in this repository
|
|
||||||
substitutes that filename. Scheduled and trigger-driven runs only
|
|
||||||
reconcile; they do not repeatedly upsert the taxonomy (the trigger's
|
|
||||||
dispatch carries `bootstrap=no`). When a ceremony pin bump adds a core
|
|
||||||
label, bump the pin first and then re-dispatch; the scheduled sweep warns
|
|
||||||
when the pinned taxonomy declares a core label the repository lacks.
|
|
||||||
|
|
||||||
## Doctrine mirror
|
## Doctrine mirror
|
||||||
|
|
||||||
Machinery is consumed by reference — GitHub fetches the workflows and
|
Machinery is consumed by reference — GitHub fetches the workflows and
|
||||||
actions above from the pin at run time — but documents have no runtime: an
|
actions above from the pin at run time — but documents have no runtime: an
|
||||||
agent reads the working tree it stands in. So the agent-facing doc set
|
agent reads the working tree it stands in. So the agent-facing doc set
|
||||||
declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**,
|
(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md,
|
||||||
|
REVIEWER.md, LABELS.md) is vendored into each consumer at **`.ceremony/`**,
|
||||||
byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md`
|
byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md`
|
||||||
marking the directory machine-managed. `actions/docs-sync` owns the copy:
|
marking the directory machine-managed. `actions/docs-sync` owns the copy:
|
||||||
`--fix` writes it (and deletes what the manifest dropped — mirror means
|
`--fix` writes it (and deletes what the manifest dropped — mirror means
|
||||||
mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale
|
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,
|
|
||||||
and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md`
|
|
||||||
only with the ordinary pin bump and re-sync, never by copying it ahead of
|
|
||||||
their pinned doctrine set.
|
|
||||||
|
|
||||||
### Read the manifest, never a copy of it
|
|
||||||
|
|
||||||
Anything on the consumer's side that needs to know *which* documents are
|
|
||||||
vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a
|
|
||||||
conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names
|
|
||||||
the files itself. The manifest is available at the pinned ref from `0.1.0`
|
|
||||||
and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the
|
|
||||||
same commit, and that tool has read it rather than a list since — and it is
|
|
||||||
one path per line, relative to ceremony's root, blank lines ignored:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# the vendored doc set at the ref this repo is pinned to
|
|
||||||
curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony/<pinned-tag>/docs/VENDORED.txt"
|
|
||||||
```
|
|
||||||
|
|
||||||
That is the whole benefit: a doctrine file added in ceremony — `RELEASES.md`
|
|
||||||
was the last, ceremony#248 — reaches every consumer at its next **ordinary
|
|
||||||
pin bump**, with **zero list edits** anywhere. A hardcoded list propagates
|
|
||||||
nothing, and its staleness is silent rather than red: `docs-sync --check`
|
|
||||||
asserts byte-identity for the files the list names and says nothing at all
|
|
||||||
about one it omits, so a consumer keeps a green guard while governing
|
|
||||||
itself with doctrine it no longer has.
|
|
||||||
|
|
||||||
What makes reading the manifest *sufficient* — rather than merely better
|
|
||||||
than a copy — is that ceremony's CI now refuses a root doctrine file that is
|
|
||||||
declared in neither the manifest nor a short in-script exemption list
|
|
||||||
(`.github/scripts/vendored-check.sh`), so the manifest at a tag is the
|
|
||||||
complete set as of that tag. That guarantee holds at `0.6.0` and later
|
|
||||||
(#251); the manifest is worth reading at every earlier pin regardless, since
|
|
||||||
it is what `actions/docs-sync` has always mirrored.
|
|
||||||
|
|
||||||
The consumer's ci.yml gains the guard alongside the others:
|
The consumer's ci.yml gains the guard alongside the others:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -732,26 +424,6 @@ Bumping the pin re-syncs the mirror in the same PR —
|
||||||
|
|
||||||
## Version pinning
|
## Version pinning
|
||||||
|
|
||||||
**Two ceremonies answer to the same version number.** `heavy-duty/ceremony`
|
|
||||||
exists on GitHub and on `forgejo.heavyduty.builders`, and the forge tree tracks
|
|
||||||
upstream's version numbers deliberately (ceremony#197 D2) — so `0.6.0` names a
|
|
||||||
different tree on each, differing by the forge-compatibility delta. They are
|
|
||||||
not forks that drifted: the forge tree carries upstream's content and adds to
|
|
||||||
it (`docs/UPSTREAM-SYNC.md`).
|
|
||||||
|
|
||||||
What that means for a consumer:
|
|
||||||
|
|
||||||
- **Name the forge you pinned, not just the tag.** `heavy-duty/ceremony@0.6.0`
|
|
||||||
is ambiguous on its own; the host in your `uses:` line is what disambiguates
|
|
||||||
it, so do not describe your pin anywhere without it.
|
|
||||||
- **A tag that exists upstream may not exist here yet.** The forge tree's
|
|
||||||
`CEREMONY_SELF_REF` takes upstream's number as soon as the sync lands, which
|
|
||||||
is *before* the release ceremony cuts that tag here. Do not bump a pin to a
|
|
||||||
version whose tag you have not confirmed on the forge you consume from.
|
|
||||||
- **The forge tree's `CHANGELOG.md` header names the upstream commit it
|
|
||||||
carries**, and `.upstream-ref` records the same SHA. That is how you tell
|
|
||||||
which `0.6.0` you are actually running.
|
|
||||||
|
|
||||||
- **Pin an exact ceremony release tag** — `@0.1.0`, never a branch and
|
- **Pin an exact ceremony release tag** — `@0.1.0`, never a branch and
|
||||||
never a moving major pointer: the family pins things and reviews
|
never a moving major pointer: the family pins things and reviews
|
||||||
updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)).
|
updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)).
|
||||||
|
|
@ -807,13 +479,7 @@ CONTRIBUTING may sharpen it, but this is the floor the guards assume:
|
||||||
kind. A repo is grouped or flat, never both (#112 D4). The assembler
|
kind. A repo is grouped or flat, never both (#112 D4). The assembler
|
||||||
merges groups in canonical order — Added, Changed, Fixed, Removed,
|
merges groups in canonical order — Added, Changed, Fixed, Removed,
|
||||||
Deprecated, Security, then anything else first-seen — and inside a
|
Deprecated, Security, then anything else first-seen — and inside a
|
||||||
group entries read newest issue first (#112 D5). Which shape binds is
|
group entries read newest issue first (#112 D5).
|
||||||
inferred from the newest published section, unless an optional sentinel
|
|
||||||
`changelog.d/shape` — one line, exactly `flat` or `grouped` — declares
|
|
||||||
it and outranks the inference (#182). To flip a repo's shape, land one
|
|
||||||
PR that adds the sentinel and converts every pending fragment to the
|
|
||||||
declared shape, bullets byte-identical; the sentinel stays after the
|
|
||||||
release, as the declaration a reader in the directory finds.
|
|
||||||
- **One line: say what changed, and stop.** Lead with the surface, not
|
- **One line: say what changed, and stop.** Lead with the surface, not
|
||||||
the mechanism — "`state:needs-human` is set at handoff" beats "the
|
the mechanism — "`state:needs-human` is set at handoff" beats "the
|
||||||
labels workflow now also wakes on `labeled`". The why and the how
|
labels workflow now also wakes on `labeled`". The why and the how
|
||||||
|
|
@ -851,16 +517,15 @@ published release.
|
||||||
|
|
||||||
## Adopting the agent team flow
|
## Adopting the agent team flow
|
||||||
|
|
||||||
The team flow (proposal → triage → work issue → build → review → human
|
The team flow (discussion → triage → issue → build → review → human
|
||||||
merge) is **optional per repo and separable from the release ceremony**:
|
merge) is **optional per repo and separable from the release ceremony**:
|
||||||
a repo can adopt release-only and take the team flow later — incubator's
|
a repo can adopt release-only and take the team flow later — incubator's
|
||||||
initial posture (#16). The model is this repo's own
|
initial posture (#16). The model is this repo's own
|
||||||
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
|
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
|
||||||
this is the checklist:
|
this is the checklist:
|
||||||
|
|
||||||
- [ ] **Open the intake door** — install `proposal.yml` and the automatic
|
- [ ] **Enable Discussions** — the triage door exists or the pipeline
|
||||||
`needs-triage` flow. A repo whose forge provides Discussions may keep
|
has no intake.
|
||||||
them as its intake door and point `config.yml`'s contact link there.
|
|
||||||
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
|
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
|
||||||
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md` —
|
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md` —
|
||||||
byte-identical to this repo at the pinned ref — plus the generated
|
byte-identical to this repo at the pinned ref — plus the generated
|
||||||
|
|
@ -894,8 +559,7 @@ this is the checklist:
|
||||||
`workflow_dispatch` once ([above](#labels-automation)), or the hand
|
`workflow_dispatch` once ([above](#labels-automation)), or the hand
|
||||||
commands in [LABELS.md](../LABELS.md).
|
commands in [LABELS.md](../LABELS.md).
|
||||||
- [ ] **State the single-writer rule** in the repo's own docs: only
|
- [ ] **State the single-writer rule** in the repo's own docs: only
|
||||||
triage mints work issues; anyone may file a proposal, which triage
|
triage mints issues; everyone else opens discussions.
|
||||||
converts or refuses.
|
|
||||||
|
|
||||||
### The pin-bump procedure
|
### The pin-bump procedure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
# Runner probes
|
|
||||||
|
|
||||||
**Not a drill.** A drill rehearses the release doors on a disposable repo and
|
|
||||||
ends. This is the opposite shape: one **standing** repo that exists so that
|
|
||||||
runner-only facts can be measured on demand, and it is **never archived**.
|
|
||||||
|
|
||||||
`heavy-duty/ceremony-runner-probe` — private, standing, reset between probes.
|
|
||||||
Ruled by the operator as option (A) of ceremony#202 (#5631).
|
|
||||||
|
|
||||||
## Why a standing repo, when drills are disposable
|
|
||||||
|
|
||||||
Some facts are only true inside Actions, under the token Actions injects, and
|
|
||||||
no local harness or PAT can reproduce them. The worked example is ceremony#192:
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /issues/{n}/labels/{id} -> 500 under ${{ github.token }} in a workflow
|
|
||||||
DELETE /issues/{n}/labels/{id} -> 204 under a maintainer PAT, same call
|
|
||||||
```
|
|
||||||
|
|
||||||
A probe that runs anywhere else passes and proves nothing. Before this venue
|
|
||||||
existed the answer was "un-archive a drill repo", which was requested three
|
|
||||||
times in two days across two issues and never became anything — the three
|
|
||||||
drill repos (`ceremony-drill-0.4.1`, `-0.4.1-final`, `-191`) are all archived,
|
|
||||||
and each was minted for one probe and then wanted again.
|
|
||||||
|
|
||||||
## The disposal rule above does NOT apply here
|
|
||||||
|
|
||||||
The rehearsal section says the builder archives the scratch repo and the
|
|
||||||
operator deletes it. **That rule is for drills.** Archiving this repo defeats
|
|
||||||
its entire purpose, and it is the failure mode the three archived drill repos
|
|
||||||
demonstrate — each was archived correctly, by the rule, and each then had to be
|
|
||||||
un-archived or replaced.
|
|
||||||
|
|
||||||
So: never archive it, never delete it, and if you find it archived, un-archive
|
|
||||||
it rather than minting a fourth one.
|
|
||||||
|
|
||||||
## Standing it up is the operator's step
|
|
||||||
|
|
||||||
Bot identities cannot create repositories in `heavy-duty`. Measured
|
|
||||||
2026-08-05 with a fleet identity holding the `repo` scope:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/orgs/heavy-duty/repos -> 403 "not allowed to create repository in organization"
|
|
||||||
POST /api/v1/user/repos -> 201 (personal namespace only)
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the same shape as the drill delete: a deliberate permission boundary,
|
|
||||||
not a misconfiguration. Do not retry it, and do not work around it by putting
|
|
||||||
the venue in a personal namespace — **not because a personal namespace is
|
|
||||||
proven unable to reach the org's runner** (that was not measured; the probe
|
|
||||||
repository above was deleted immediately, so nothing about runner or secret
|
|
||||||
reach was established), but because @andres ruled an **org-owned standing
|
|
||||||
venue** (#5631). A personally-owned repo is a different thing from the one that
|
|
||||||
was decided on, and cannot satisfy #202's named acceptance target.
|
|
||||||
|
|
||||||
If runner or secret reach turns out to matter, measure it once the venue
|
|
||||||
exists rather than assuming it here.
|
|
||||||
|
|
||||||
## Running a probe
|
|
||||||
|
|
||||||
1. Reset the repo to a clean state — the probe's own fixtures only, no
|
|
||||||
leftovers from the last one. A probe that inherits state is a probe whose
|
|
||||||
result you cannot attribute.
|
|
||||||
2. **Arm it against the candidate** (below) — two layers, candidate code and
|
|
||||||
armed workflow — if the probe is about ceremony's own machinery rather than
|
|
||||||
about a bare API call.
|
|
||||||
3. **Run it as an Actions job under `${{ github.token }}`.** This is the whole
|
|
||||||
point of the venue and the one step that cannot be shortcut. A `curl` from a
|
|
||||||
laptop with a PAT answers a different question — see the 204/500 split
|
|
||||||
above — and a probe run that way is worse than no probe, because it produces
|
|
||||||
a confident wrong answer.
|
|
||||||
4. **The job writes its raw results into an issue in the PROBE repo** —
|
|
||||||
`heavy-duty/ceremony-runner-probe` — not into ceremony. Logs age out;
|
|
||||||
ceremony#192's run 701 survived only because the job wrote its findings
|
|
||||||
into an issue it created.
|
|
||||||
5. **A human then records the probe issue's URL and the Actions run number on
|
|
||||||
the ceremony issue the probe serves.** That hop is deliberate and is the
|
|
||||||
whole of the boundary: the probe workflow holds no credential and no code
|
|
||||||
path that can write to `heavy-duty/ceremony`, so "the probe reports its
|
|
||||||
findings" and "the probe cannot touch the live board" stay compatible
|
|
||||||
rather than contradicting each other (@codex-reviewer-andresmgsl, #202
|
|
||||||
review).
|
|
||||||
|
|
||||||
## Arming a candidate ref
|
|
||||||
|
|
||||||
A probe that exercises ceremony's own machinery needs the candidate tree
|
|
||||||
reachable from a `uses:` line. This is the fork-ref shape `drills/README.md`
|
|
||||||
step 2 points at, written out — and it has **two layers**, which is the part
|
|
||||||
that is easy to get wrong and impossible to fix afterwards.
|
|
||||||
|
|
||||||
**Why two.** The candidate's own workflows contain
|
|
||||||
`repository: heavy-duty/ceremony` beside `ref: ${{ env.CEREMONY_SELF_REF }}`,
|
|
||||||
so they must be rewritten to point at the fork and at the candidate. But
|
|
||||||
rewriting them **creates a new commit**, and a commit cannot contain its own
|
|
||||||
object ID. A single-layer arming is therefore self-referential: pin the callers
|
|
||||||
to the pre-rewrite SHA and they load the *unarmed* workflows; pin them to the
|
|
||||||
post-rewrite one and you are asking a commit to embed itself
|
|
||||||
(@codex-reviewer-andresmgsl, #202 review).
|
|
||||||
|
|
||||||
So:
|
|
||||||
|
|
||||||
| layer | what it is | what it carries |
|
|
||||||
|---|---|---|
|
|
||||||
| **candidate code SHA** | the immutable tree under test | `actions/`, `lib/` — untouched |
|
|
||||||
| **armed workflow SHA** | a small child commit on top of it | workflows rewritten to the fork + `CEREMONY_SELF_REF` = the candidate code SHA |
|
|
||||||
|
|
||||||
### The procedure
|
|
||||||
|
|
||||||
1. **Push the candidate tree** to a fork under the identity that will run the
|
|
||||||
probe — one branch, `<identity>/ceremony@probe-<issue>` — and record its
|
|
||||||
SHA. Steps 1 and 2 advance the tip of that **same** branch; there are two
|
|
||||||
commits, not two branches. That is
|
|
||||||
the **candidate code SHA**. Never create a branch on
|
|
||||||
`heavy-duty/ceremony` named like a tag: it shadows that tag for every
|
|
||||||
consumer until somebody remembers to delete it.
|
|
||||||
2. **Write the manifest FIRST, from the pre-arming tree, then commit the
|
|
||||||
arming.** The manifest enumerates the carriers *that must change*, so it is
|
|
||||||
generated before they do — running it afterwards would enumerate
|
|
||||||
already-rewritten rows and lose the canonical internal-checkout ones
|
|
||||||
entirely (@codex-reviewer-andresmgsl, #202 review). In that same
|
|
||||||
fork branch rewrite, for **every** carrier the manifest below enumerates:
|
|
||||||
ceremony's own internal `repository:` checkouts → `<identity>/ceremony`, and
|
|
||||||
**every** `CEREMONY_SELF_REF` value → the **candidate code SHA** from step 1.
|
|
||||||
There were three self-ref carriers on `main` at the time of writing and the
|
|
||||||
count is not a constant — derive it, do not remember it
|
|
||||||
(@glm-reviewer-andresmgsl, @codex-reviewer-andresmgsl, #202 review). The
|
|
||||||
**consumer** checkouts (`${{ github.repository }}`) are left alone. Record
|
|
||||||
the resulting SHA: that is the **armed workflow SHA**.
|
|
||||||
3. **Pin the probe repo's callers by layer**, because they are not the same
|
|
||||||
thing:
|
|
||||||
- composite-action callers →
|
|
||||||
`<identity>/ceremony/actions/<name>@<candidate-code-sha>`;
|
|
||||||
- reusable-workflow callers →
|
|
||||||
`<identity>/ceremony/.github/workflows/<file>@<armed-workflow-sha>`, since
|
|
||||||
that is the only revision whose inner checkout is rewritten.
|
|
||||||
4. **Gate the arming against a MANIFEST, byte for byte.** Every weaker shape
|
|
||||||
has a hole, and each of these was found in a published draft of this file
|
|
||||||
(@codex-reviewer-andresmgsl, #202 review):
|
|
||||||
|
|
||||||
| weaker check | what slips through |
|
|
||||||
|---|---|
|
|
||||||
| "the old literal is absent" | a carrier rewritten to the wrong fork, or to the *armed* SHA |
|
|
||||||
| "every extracted value equals X" | a carrier that **vanished** — nothing to compare |
|
|
||||||
| "each value is one of {fork, dynamic}" | a **role swap**: an internal checkout made dynamic, a consumer checkout pointed at the fork |
|
|
||||||
| "the SHA suffix matches" | `wrong-owner/ceremony/actions/foo@<right-sha>` |
|
|
||||||
| "known callers match" | an **unrecognised** caller, or none at all |
|
|
||||||
| "the owner and the sha are right for the kind" | a **layer swap**: `…/actions/x@<armed>` labelled a workflow caller satisfies both |
|
|
||||||
|
|
||||||
So the arming step **writes a manifest** — one line per carrier, `path`,
|
|
||||||
`kind`, `full expected value` — and the gate compares the tree's actual
|
|
||||||
carriers against it as a set. A deletion, a role swap, a wrong fork, a wrong
|
|
||||||
SHA, an extra carrier and a missing caller are then all the same kind of
|
|
||||||
failure: the sets differ.
|
|
||||||
|
|
||||||
**Generate it while arming**, from the tree you are arming, so the manifest
|
|
||||||
cannot drift from the repository:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# write-manifest <candidate-checkout> <probe-checkout> <fork> <code-sha> <armed-sha>
|
|
||||||
#
|
|
||||||
# Run against the PRE-ARMING tree and the UNPINNED probe: this records what
|
|
||||||
# each carrier must BECOME, so it has to see them before they change.
|
|
||||||
#
|
|
||||||
# `|| true` on every extraction, for the same reason the checker needs it:
|
|
||||||
# git grep exits 1 on no-match and `set -e` would abort BEFORE the manifest
|
|
||||||
# is written — silently, which is how the first version of this generator
|
|
||||||
# produced no file and no diagnostic when a probe exercised only one layer
|
|
||||||
# (@codex-reviewer-andresmgsl). A probe need not use both.
|
|
||||||
set -euo pipefail
|
|
||||||
candidate="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"
|
|
||||||
# shellcheck disable=SC2016 # `${{ github.repository }}` is literal YAML, not a shell expansion
|
|
||||||
{
|
|
||||||
git -C "$candidate" grep -n 'CEREMONY_SELF_REF:' -- .github/workflows \
|
|
||||||
| cut -d: -f1,2 | sed "s|$|\tself_ref\t$code_sha|" || true
|
|
||||||
git -C "$candidate" grep -n 'repository: heavy-duty/ceremony' -- .github/workflows \
|
|
||||||
| cut -d: -f1,2 | sed "s|$|\tinternal_repo\t$fork|" || true
|
|
||||||
git -C "$candidate" grep -n 'repository: ${{ github.repository }}' -- .github/workflows \
|
|
||||||
| cut -d: -f1,2 | sed 's|$|\tconsumer_repo\t${{ github.repository }}|' || true
|
|
||||||
# Callers record the COMPLETE expected coordinate, not just the sha: the
|
|
||||||
# path is as rewritable as the owner, and a manifest that stores only the
|
|
||||||
# suffix cannot notice `…/actions/wrong-one@<right-sha>`.
|
|
||||||
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/\.github/workflows/' -- .github \
|
|
||||||
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(\.github/workflows/[^@[:space:]]+)@.*|\\1:\\2\\tworkflow_caller\\t$fork/\\3@$armed_sha|" || true
|
|
||||||
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/actions/' -- .github \
|
|
||||||
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(actions/[^@[:space:]]+)@.*|\\1:\\2\\taction_caller\\t$fork/\\3@$code_sha|" || true
|
|
||||||
} | sort >manifest.tsv
|
|
||||||
|
|
||||||
# Zero ceremony callers is a refusal by name; one layer only is fine.
|
|
||||||
callers="$(grep -cE '(workflow|action)_caller' manifest.tsv || true)"
|
|
||||||
[ "$callers" -gt 0 ] || { echo "manifest: no ceremony callers found in $probe" >&2; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Then arm — rewrite and commit — and check the result against it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# check-arming <armed-checkout> <probe-checkout> <fork> <code-sha> <armed-sha> <manifest.tsv>
|
|
||||||
set -euo pipefail
|
|
||||||
armed="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"; manifest="$6"
|
|
||||||
fail() { echo "arming incomplete: $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
# `|| true` on every extraction: git grep exits 1 when nothing matches, and
|
|
||||||
# under `set -e` that would kill this script BEFORE the comparison — so a
|
|
||||||
# carrier class that vanished ENTIRELY produced silence instead of a
|
|
||||||
# refusal. Silence is the worst of the three outcomes; the comparison below
|
|
||||||
# is what must report it.
|
|
||||||
[ "$(grep -cE '(workflow|action)_caller' "$manifest" || true)" -gt 0 ] \
|
|
||||||
|| fail "manifest names no ceremony callers — it cannot prove an arming"
|
|
||||||
|
|
||||||
# THE MANIFEST ITSELF IS CHECKED AGAINST THE TARGET, not trusted. Comparing
|
|
||||||
# only tree-vs-manifest proves consistency, and a manifest generated with the
|
|
||||||
# armed SHA where the candidate SHA belonged — or with the wrong fork —
|
|
||||||
# describes a WRONG arming perfectly. The tree would then match it and the
|
|
||||||
# gate would pass (@codex-reviewer-andresmgsl, #202 review).
|
|
||||||
# shellcheck disable=SC2016 # `${{ github.repository }}` below is literal YAML
|
|
||||||
while IFS=$'\t' read -r loc kind want; do
|
|
||||||
case "$kind" in
|
|
||||||
self_ref) [ "$want" = "$code_sha" ] || fail "manifest $loc: self_ref should be the CANDIDATE sha" ;;
|
|
||||||
internal_repo) [ "$want" = "$fork" ] || fail "manifest $loc: internal repo should be $fork" ;;
|
|
||||||
consumer_repo) [ "$want" = '${{ github.repository }}' ] \
|
|
||||||
|| fail "manifest $loc: consumer checkout must stay dynamic" ;;
|
|
||||||
# THE KIND MUST BIND TO THE PATH CLASS, not only to the owner and the
|
|
||||||
# sha. The path class is what SAYS which layer a caller is, so checking
|
|
||||||
# the sha against the kind while letting the kind float free accepts a
|
|
||||||
# consistent layer swap — `…/actions/x@<armed>` declared workflow_caller
|
|
||||||
# passes every owner and sha test (@codex-reviewer-andresmgsl, #202
|
|
||||||
# review). Decompose once, then let the kind fix BOTH coordinates.
|
|
||||||
workflow_caller|action_caller)
|
|
||||||
owner="${want%%/ceremony/*}"; rest="${want#*/ceremony/}"
|
|
||||||
path="${rest%@*}"; sha="${want##*@}"
|
|
||||||
[ "$owner/ceremony" = "$fork" ] \
|
|
||||||
|| fail "manifest $loc: caller owner should be $fork"
|
|
||||||
case "$kind" in
|
|
||||||
workflow_caller)
|
|
||||||
case "$path" in .github/workflows/?*) : ;;
|
|
||||||
*) fail "manifest $loc: workflow_caller must resolve at .github/workflows/<file>, not '$path'" ;;
|
|
||||||
esac
|
|
||||||
[ "$sha" = "$armed_sha" ] || fail "manifest $loc: workflow caller should be the ARMED sha" ;;
|
|
||||||
action_caller)
|
|
||||||
case "$path" in actions/?*) : ;;
|
|
||||||
*) fail "manifest $loc: action_caller must resolve at actions/<name>, not '$path'" ;;
|
|
||||||
esac
|
|
||||||
[ "$sha" = "$code_sha" ] || fail "manifest $loc: action caller should be the CANDIDATE sha" ;;
|
|
||||||
esac ;;
|
|
||||||
*) fail "manifest $loc: unknown kind '$kind'" ;;
|
|
||||||
esac
|
|
||||||
done <"$manifest"
|
|
||||||
|
|
||||||
actual="$(mktemp)"; trap 'rm -f "$actual"' EXIT
|
|
||||||
{
|
|
||||||
git -C "$armed" grep -nP '(?<=CEREMONY_SELF_REF: ")[^"]+' -- .github/workflows \
|
|
||||||
| sed -E 's/^([^:]+):([0-9]+):.*CEREMONY_SELF_REF: "([^"]*)".*/\1:\2\tself_ref\t\3/' || true
|
|
||||||
git -C "$armed" grep -nE 'repository: .+' -- .github/workflows \
|
|
||||||
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*repository:[[:space:]]*(.*)$|\1:\2\t__repo__\t\3|' || true
|
|
||||||
# Only CEREMONY callers, matching the generator's domain exactly — a
|
|
||||||
# third-party `actions/checkout` is not this gate's business, and
|
|
||||||
# extracting it here while the generator ignores it made every probe fail
|
|
||||||
# as an "unrecognised carrier" (@codex-reviewer-andresmgsl). A wrong OWNER
|
|
||||||
# is still caught: `wrong-owner/ceremony/...` matches this pattern.
|
|
||||||
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/' -- .github \
|
|
||||||
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.*)$|\1:\2\t__uses__\t\3|' || true
|
|
||||||
} | sort >"$actual"
|
|
||||||
|
|
||||||
# every manifest line must be present with its EXACT expected value, and the
|
|
||||||
# kinds must match — a role swap changes the kind, not just the value.
|
|
||||||
while IFS=$'\t' read -r loc kind want; do
|
|
||||||
case "$kind" in
|
|
||||||
self_ref) have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="self_ref"{print $3}' "$actual")" ;;
|
|
||||||
internal_repo|consumer_repo)
|
|
||||||
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__repo__"{print $3}' "$actual")" ;;
|
|
||||||
workflow_caller|action_caller)
|
|
||||||
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__uses__"{print $3}' "$actual")" ;;
|
|
||||||
esac
|
|
||||||
[ -n "$have" ] || fail "carrier vanished: $loc ($kind)"
|
|
||||||
# ONE comparison for every kind: the manifest already carries the complete
|
|
||||||
# expected value, so owner, path AND sha are checked at once. Checking the
|
|
||||||
# owner and the sha separately let `…/actions/wrong-one@<right-sha>`
|
|
||||||
# through (@codex-reviewer-andresmgsl).
|
|
||||||
[ "$have" = "$want" ] || fail "$loc ($kind): expected '$want', found '$have'"
|
|
||||||
done <"$manifest"
|
|
||||||
|
|
||||||
# and nothing UNRECOGNISED: every uses:/repository: in the trees must appear
|
|
||||||
# in the manifest, so an added carrier is a failure rather than a silence.
|
|
||||||
while IFS=$'\t' read -r loc _ _; do
|
|
||||||
grep -qF "$loc"$'\t' "$manifest" || fail "carrier not in manifest: $loc"
|
|
||||||
done <"$actual"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why a manifest rather than a longer list of assertions.** The carrier set
|
|
||||||
is a property of the tree at the moment of arming; any list written into
|
|
||||||
this document is stale the next time a workflow is added. The manifest is
|
|
||||||
generated from the tree, recorded in the result issue (step 6), and is the
|
|
||||||
thing a later reader compares against — so "what was armed" is evidence
|
|
||||||
rather than recollection.
|
|
||||||
|
|
||||||
5. **Invoke the probe by the event it is about**, and record which: a
|
|
||||||
`workflow_dispatch`, or the real board event under test. A probe that fires
|
|
||||||
a different event than the one under test proves something else.
|
|
||||||
6. **The result issue records all of it**: the fork repository, the candidate
|
|
||||||
code SHA, the armed workflow SHA, every rewritten carrier, the workflow
|
|
||||||
invoked and the run number. Those are what make the result reproducible;
|
|
||||||
without the two SHAs distinguished, a later reader cannot tell which tree
|
|
||||||
answered.
|
|
||||||
7. **Reset removes the candidate-specific EXECUTABLE state**: the caller stubs,
|
|
||||||
the probe workflow, and the fork's probe branch — whose tip carries both the
|
|
||||||
candidate commit and the armed commit on top of it — so the next probe
|
|
||||||
cannot inherit a pin it did not choose. **Result issues are never deleted.**
|
|
||||||
They may be closed or relabelled; deleting them would recreate the
|
|
||||||
expiring-log problem this venue exists to avoid.
|
|
||||||
|
|
||||||
## Who may reset it
|
|
||||||
|
|
||||||
**Operator-owned until ruled otherwise.** #202's task 4 asks who may reset the
|
|
||||||
venue, and creating the repo is the operator's step, so the access policy is
|
|
||||||
his to set at the same time (@codex-reviewer-andresmgsl, #202 review).
|
|
||||||
|
|
||||||
Two levels, deliberately separated:
|
|
||||||
|
|
||||||
- **content reset** — removing probe branches, workflows and fixtures; the
|
|
||||||
ordinary between-probes operation. It does **not** include deleting result
|
|
||||||
issues, which are the evidence and are immutable once written
|
|
||||||
(@codex-reviewer-andresmgsl, #202 review);
|
|
||||||
- **archive / delete / admin** — which is where the drill rule's damage came
|
|
||||||
from, and which no bot identity should hold here.
|
|
||||||
|
|
||||||
If fleet identities are given push access for content reset, this section
|
|
||||||
records that; until then, ask.
|
|
||||||
|
|
||||||
## What must never happen here
|
|
||||||
|
|
||||||
No probe touches `heavy-duty/ceremony`'s board. No labels, no comments, no
|
|
||||||
runs attributable to a probe. The venue exists so that the live board does not
|
|
||||||
have to be the test fixture.
|
|
||||||
|
|
||||||
## The probes this venue owes — and the records of those delivered
|
|
||||||
|
|
||||||
Delivered probes stay listed with their record: the venue's value is that a
|
|
||||||
claim like "the asymmetry reproduces" carries a URL a reader can open, not a
|
|
||||||
memory.
|
|
||||||
|
|
||||||
- **ceremony#192** — DELIVERED, first drill (2026-08-05). Under
|
|
||||||
`${{ github.token }}` in the venue:
|
|
||||||
`DELETE /issues/{n}/labels/{id}` → **500**, the label still on the issue
|
|
||||||
afterward — the failure observable in the set, not merely a status — then
|
|
||||||
`PUT` full-set clear → **200**, set actually empty. Record:
|
|
||||||
[probe issue #1](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/1)
|
|
||||||
(run 1) and
|
|
||||||
[probe issue #2](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/2)
|
|
||||||
(run 4 — the clean independent repeat after the redaction incident below).
|
|
||||||
- **ceremony#205** — DELIVERED with a correction to the line above's
|
|
||||||
premise. The dispatch route answers **204** to a valid body carrying the
|
|
||||||
bare resolvable ref `main` — under the workflow token
|
|
||||||
([ceremony#205 comment #6262](https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/205#issuecomment-6262),
|
|
||||||
run 504, and again as
|
|
||||||
[probe issue #4](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4),
|
|
||||||
run 6) and under a PAT
|
|
||||||
([probe issue #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/5),
|
|
||||||
run 7, and ceremony run 459). The earlier opaque `500` came from a bare
|
|
||||||
UNRESOLVABLE ref or an unknown/unparseable workflow — the diagnostic !213
|
|
||||||
ships now names this; a fully-qualified bad ref gets a clean 404 instead.
|
|
||||||
The `GET /actions/workflows` listing still 404s. Claims here are limited to
|
|
||||||
what those runs measured.
|
|
||||||
- **ceremony#215** — the discriminator drill: REST-body `inputs` DO reach a
|
|
||||||
top-level dispatched workflow, both contexts
|
|
||||||
(`inputs.*` and `github.event.inputs.*`), both identities. What loses the
|
|
||||||
value is the `workflow_call` boundary — a called workflow does not see the
|
|
||||||
caller's `event.inputs` on this instance. Records:
|
|
||||||
[probe issues #4 and #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4)
|
|
||||||
(runs 6 and 7).
|
|
||||||
- **ceremony#217** — DELIVERED (2026-08-09). The standing venue used the
|
|
||||||
published consumer callers without rewrites or bypasses:
|
|
||||||
`labels.yml@0.6.1` and `labels-sweep.yml@0.6.1`. Opening fixture issue #7
|
|
||||||
drove event caller
|
|
||||||
[run 23](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/23)
|
|
||||||
and its dispatched sweep
|
|
||||||
[run 24](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/24);
|
|
||||||
the sweep wrote `needs-triage` on that issue under the workflow token. A
|
|
||||||
separate manual sweep was green in
|
|
||||||
[run 25](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/25).
|
|
||||||
Observer
|
|
||||||
[run 30](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/30)
|
|
||||||
recorded the released tag SHA `338cf5f754f0e87feefe9231b47910fb236ab4d0`,
|
|
||||||
both caller runs and the resulting label set in
|
|
||||||
[probe issue #10](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/10).
|
|
||||||
The event caller loaded its reusable workflow at `@0.6.1`; both sweep runs
|
|
||||||
completed the reusable workflow's internal `CEREMONY_SELF_REF` checkout at
|
|
||||||
the real `0.6.1` tag. There was no candidate SHA rewrite and no dogfood
|
|
||||||
bypass.
|
|
||||||
|
|
||||||
Two venue lessons from the first drills, kept where the next probe author will
|
|
||||||
look:
|
|
||||||
|
|
||||||
- **Rule 4 is load-bearing on this instance**: the probe repo's web log route
|
|
||||||
404s for non-admin reads, and a log-only observation (run 2) was lost where
|
|
||||||
issue-written ones were not.
|
|
||||||
- **Report content sent to the forge must never contain a credential
|
|
||||||
expression OR a credential value** — moving a secret from `${{ … }}` syntax
|
|
||||||
into a shell variable does not make it safe to emit. Name identities in
|
|
||||||
literal prose ("the workflow token"), allowlist the fields a report emits,
|
|
||||||
and treat anything else as unsendable. The incident this teaches from: an
|
|
||||||
escaped `${{ github.token }}` in a comment template was expanded by the
|
|
||||||
runner into the recorded text (the run's ephemeral token); it was redacted
|
|
||||||
in place and the drill repeated clean (probe issue #2, run 4).
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
# Syncing this tree with upstream ceremony
|
|
||||||
|
|
||||||
`heavy-duty/ceremony` exists on two forges and they diverge in opposite
|
|
||||||
directions on purpose:
|
|
||||||
|
|
||||||
- **upstream** — `github.com/heavy-duty/ceremony`, where new ceremony features
|
|
||||||
are written. **Read-only from here.** No issue, PR, comment, review or
|
|
||||||
release is ever created there.
|
|
||||||
- **this tree** — `forgejo.heavyduty.builders/heavy-duty/ceremony`, which
|
|
||||||
carries upstream's content plus the forge-compatibility delta and never
|
|
||||||
writes back.
|
|
||||||
|
|
||||||
This document is the procedure for bringing upstream's work across. It is
|
|
||||||
written to be followed without prior context; where it states a resolution, the
|
|
||||||
resolution is standing and does not get re-decided each sync.
|
|
||||||
|
|
||||||
Worked example throughout: the `0.6.0` sync (#197, #198), which merged upstream
|
|
||||||
`8c3a4d1` onto `dad99dd` and took four heads to get green.
|
|
||||||
|
|
||||||
## 0.6.2 port record
|
|
||||||
|
|
||||||
On 2026-08-24 this tree released the content carried by upstream
|
|
||||||
`upstream-0.6.1` through `upstream-0.6.3`. The content baseline is
|
|
||||||
`upstream-0.6.3`; the changes were ported onto the Forgejo-adapted tree by
|
|
||||||
#229 and #230 rather than merged from upstream.
|
|
||||||
|
|
||||||
The ancestry baseline therefore remains the full `.upstream-ref` value
|
|
||||||
`8c3a4d1dee2bdb5ac06a632a285bb65ab2615214` (upstream `0.6.0`, merged by
|
|
||||||
#198). No upstream ancestry moved in this release. Tags are disambiguated as
|
|
||||||
`upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this
|
|
||||||
forge.
|
|
||||||
|
|
||||||
Upstream's drill-record fixes and the upstream `0.7.x` line remain deferred to
|
|
||||||
the next sync campaign. That line has no ceiling this file can hold still —
|
|
||||||
upstream tags roughly one release a week — so what is recorded here is its
|
|
||||||
floor and the date it was last measured: `0.7.0` onward, `0.7.6` newest as
|
|
||||||
measured 2026-08-27. Run `git ls-remote --tags` for today's ceiling. The
|
|
||||||
sentence this replaced froze `0.7.4` and was already a release behind on the
|
|
||||||
day it was written.
|
|
||||||
|
|
||||||
## The next campaign merges
|
|
||||||
|
|
||||||
The next sync campaign is a merge, not a port, and it therefore advances
|
|
||||||
`.upstream-ref` to the commit it merges. The operator ruled this on #268 on
|
|
||||||
2026-08-27, when release-init found no forge-local work to fill an `0.6.4` and
|
|
||||||
opened no window.
|
|
||||||
|
|
||||||
The reason is the ancestry baseline. `.upstream-ref` has been pinned at
|
|
||||||
`8c3a4d1` — upstream `0.6.0`, merged by #198 — because 0.6.2 came across as a
|
|
||||||
port and a port moves no ancestry. Another port would leave it pinned and
|
|
||||||
guarantee a third, larger campaign against a delta that is still growing.
|
|
||||||
|
|
||||||
Size it before starting. With upstream's tags fetched read-only per step 1,
|
|
||||||
`git diff --shortstat 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 0.7.6`
|
|
||||||
was 92 files and +25,121/-971 over 488 commits when measured 2026-08-27, and
|
|
||||||
the three action scripts the forge delta is heaviest in all move:
|
|
||||||
`runner-isolated` +1619, `issueflow-reconcile` +913, `labels-reconcile` +889.
|
|
||||||
Step 4 of the procedure below — the audit of what merged *without* conflicting
|
|
||||||
— is where that size is actually paid for.
|
|
||||||
|
|
||||||
## The standing resolutions
|
|
||||||
|
|
||||||
These recur every sync. They are decided; re-deciding them is the cost this
|
|
||||||
list exists to remove.
|
|
||||||
|
|
||||||
| what | which side wins | decided by |
|
|
||||||
|---|---|---|
|
|
||||||
| `VERSION` | **upstream** — this tree tracks upstream's version numbers | #197 D2 |
|
|
||||||
| `CEREMONY_SELF_REF` (both carriers) | **upstream** | #197 D2 |
|
|
||||||
| `.github/labels.conf` | **this tree** — upstream's roster names identities that do not exist here | #195 |
|
|
||||||
| `drills/*.md` | **this tree** — a drill record is a record of a run *here* | #198 |
|
|
||||||
| `CHANGELOG.md` | **both**, upstream's new sections above this tree's | #198 |
|
|
||||||
| a section for a version **both** trees released | **this tree's** — ours is the published body of the tag that exists here | #198 |
|
|
||||||
|
|
||||||
Two consequences worth stating plainly:
|
|
||||||
|
|
||||||
- **Two trees answer to the same version number**, differing by the forge
|
|
||||||
delta. That is accepted, not accidental (#197 D2). The mitigation is
|
|
||||||
provenance in prose: `CHANGELOG.md`'s header names the upstream commit this
|
|
||||||
tree carries, and `.upstream-ref` records it in machine-readable form.
|
|
||||||
- **A tag that exists upstream may not exist here.** `CEREMONY_SELF_REF` takes
|
|
||||||
upstream's number, and both workflows carry the self-consumption bypass
|
|
||||||
(`if: github.repository != 'heavy-duty/ceremony'`), so ceremony's own CI is
|
|
||||||
unaffected. But **no consumer may bump its pin to that number until the
|
|
||||||
release ceremony cuts the tag here.**
|
|
||||||
|
|
||||||
## The procedure
|
|
||||||
|
|
||||||
### 1. Add the upstream remote, read-only, and confirm the merge base
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git remote add gh https://github.com/heavy-duty/ceremony.git # if absent
|
|
||||||
git fetch gh
|
|
||||||
upstream_sha="$(git rev-parse gh/main)" # capture ONCE, in full
|
|
||||||
git merge-base main "$upstream_sha"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Capture the full SHA immediately and use that value everywhere after** — the
|
|
||||||
merge, the provenance, the `.upstream-ref` write. `gh/main` is a moving
|
|
||||||
pointer: while this sync was being reviewed upstream advanced from `8c3a4d1`
|
|
||||||
to `08e2912`, and re-reading `gh/main` at recording time would have written a
|
|
||||||
commit this tree does not contain. The recorded ref is *what was merged*, never
|
|
||||||
*what upstream is now*.
|
|
||||||
|
|
||||||
**Confirm the merge base against `.upstream-ref` before merging anything.** If
|
|
||||||
it is not what the last sync recorded, something moved — stop and re-measure
|
|
||||||
rather than proceeding. A sync that starts from an unexpected base is a sync
|
|
||||||
whose conflict count means nothing.
|
|
||||||
|
|
||||||
### 2. Merge, never rebase
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git merge "$upstream_sha"
|
|
||||||
```
|
|
||||||
|
|
||||||
One merge commit, conflicts resolved once (#197 D1). Rebasing the forge-only
|
|
||||||
commits onto upstream would rewrite every SHA, re-resolve the same conflicts
|
|
||||||
once per commit, and break any pin to them. A fresh re-import would discard the
|
|
||||||
provenance in this repo's issue comments, which is where its documentation
|
|
||||||
actually lives.
|
|
||||||
|
|
||||||
### 3. Resolve the conflicts
|
|
||||||
|
|
||||||
Apply the standing resolutions above. What is left is genuinely new and needs
|
|
||||||
judgement — in the `0.6.0` sync that was 5 hunks of 18.
|
|
||||||
|
|
||||||
### 4. Audit what the merge brought in that did NOT conflict
|
|
||||||
|
|
||||||
**This is the step the `0.6.0` sync nearly shipped without, and the one this
|
|
||||||
document exists for.**
|
|
||||||
|
|
||||||
`git merge` takes upstream's side wherever only upstream moved a region. So a
|
|
||||||
function upstream *added* to a file this tree already owns arrives with **no
|
|
||||||
conflict and no question asked**. Reviewing the conflict hunks cannot find
|
|
||||||
them: four reviewers read the same diff and each found a different subset.
|
|
||||||
|
|
||||||
In the `0.6.0` sync that was **eight** runtime `gh` call sites, in three files
|
|
||||||
and two file types, every one of which #188 had previously removed.
|
|
||||||
|
|
||||||
So, after resolving:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bash test/no-runtime-gh.test.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
That guard is the mechanical form of #197's acceptance bar — no runtime `gh`
|
|
||||||
outside `lib/forge-github.sh` unless the file declares
|
|
||||||
`CEREMONY_FORGE_CLIENT=gh` **and** refuses when it cannot run. Do not satisfy
|
|
||||||
it by adding an exemption; a declaration without a refusal is a permission slip
|
|
||||||
for `gh: command not found`.
|
|
||||||
|
|
||||||
Then check the **variables** the same way, because the same mechanic applies to
|
|
||||||
state: if a conflicted region assigns something that auto-merged code consumes,
|
|
||||||
resolving it "to this tree's side" silently removes the producer. Every one of
|
|
||||||
those consumers degrades to empty rather than erroring, so nothing goes red.
|
|
||||||
The `0.6.0` sync had three such seams. Enumerate what each resolved region
|
|
||||||
assigns, and confirm each still has a producer.
|
|
||||||
|
|
||||||
### 5. Port or declare every new `gh` call site
|
|
||||||
|
|
||||||
Where a `forge_*` verb exists, port it in the merge itself. Where none does,
|
|
||||||
the file **declares** `CEREMONY_FORGE_CLIENT=gh` and refuses loudly, and the
|
|
||||||
port gets its own issue (#199 for `refs-not-closing`, #205 for the sweep
|
|
||||||
dispatch). "Never 'probably github'" applies to the sync as much as to a
|
|
||||||
runtime probe.
|
|
||||||
|
|
||||||
A workflow cannot call `forge_preflight`, so it declares in its `env:` block
|
|
||||||
and refuses inline — deciding the **forge** first and the **binary** second. A
|
|
||||||
guard that only asks whether `gh` is installed passes the moment a runner image
|
|
||||||
ships it.
|
|
||||||
|
|
||||||
### 6. Record the provenance
|
|
||||||
|
|
||||||
- `CHANGELOG.md`'s header: which upstream commit this tree now carries.
|
|
||||||
- `.upstream-ref`: the same **full 40-character** SHA, machine-readable,
|
|
||||||
checked by `test/upstream-delta.test.sh` — which refuses when the object is
|
|
||||||
absent or is not an ancestor, rather than reporting it unverifiable. `ci.yml`
|
|
||||||
fetches that exact object before the suite runs.
|
|
||||||
- A `changelog.d/` fragment for the sync issue.
|
|
||||||
|
|
||||||
### 7. Verify — and verify where it will actually run
|
|
||||||
|
|
||||||
`test/run.sh` green on your machine is the weakest of the checks below. The
|
|
||||||
`0.6.0` sync was "green locally" and red on the runner **three times, for three
|
|
||||||
different reasons**:
|
|
||||||
|
|
||||||
| what was green locally | why the runner disagreed |
|
|
||||||
|---|---|
|
|
||||||
| `shellcheck-all.sh` | it lints **tracked** files, and the new guard was untracked |
|
|
||||||
| the whole suite | CI pins **shellcheck 0.10.0**; a different local version reports differently |
|
|
||||||
| `issue_payload_valid` | `jq -e` on empty input exits **4** on jq 1.7 and **0** on jq 1.6 — and the runner image ships 1.6 |
|
|
||||||
|
|
||||||
That last one was not a test problem: on jq 1.6 the guard that refuses an
|
|
||||||
unreadable read was *accepting* one. **The distance between your environment
|
|
||||||
and the runner's is part of the sync's risk surface, not an inconvenience.**
|
|
||||||
|
|
||||||
So verify with the runner's own tooling:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git add -A # or shellcheck sees nothing new
|
|
||||||
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
|
|
||||||
bash .github/scripts/shellcheck-all.sh # pinned 0.10.0, as ci.yml installs
|
|
||||||
bash .github/scripts/actionlint-all.sh
|
|
||||||
bash .github/scripts/self-ref-check.sh
|
|
||||||
bash .github/scripts/marker-check.sh
|
|
||||||
bash .github/scripts/vendored-check.sh
|
|
||||||
bash actions/changelog-armed/changelog-armed.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
and run the suite once under the runner's `jq` as well as your own.
|
|
||||||
|
|
||||||
### Every branch that was open during the sync is now stale
|
|
||||||
|
|
||||||
Forgejo tests branch heads; it never tests what two branches produce together,
|
|
||||||
and it never re-tests an open PR when `main` moves under it. So after a sync
|
|
||||||
lands, **every PR that was open across it is green against a tree that no
|
|
||||||
longer exists** — its run did not contain the test files and rules the sync
|
|
||||||
introduced.
|
|
||||||
|
|
||||||
Both halves of that bit in this sync:
|
|
||||||
|
|
||||||
- `#206` and `#207` were cut from the pre-sync base. Their green suites had 22
|
|
||||||
test files; the merged tree has 28.
|
|
||||||
- `#206`'s changelog fragment was individually green and made the **combined**
|
|
||||||
tree red, because the terminal-citation rule (#262) arrives *with* the sync
|
|
||||||
and the fragment was written against a base without it.
|
|
||||||
|
|
||||||
So, for each PR still open:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git merge origin/main # in the branch — do not rewrite its commits
|
|
||||||
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
or, if you are only checking rather than updating, merge them into a scratch
|
|
||||||
worktree together and run the full current suite and static guards there. A
|
|
||||||
prior approval is evidence about the tree it was given on; after a sync it is
|
|
||||||
not evidence about the tree the operator would merge.
|
|
||||||
|
|
||||||
### 8. After it merges — audit by executed steps, never by colour
|
|
||||||
|
|
||||||
The sync issue uses `Refs`, not `Closes`, and stays open until a real sweep on
|
|
||||||
the merged `main` is linked to it.
|
|
||||||
|
|
||||||
**A green run is not that evidence.** In this sync the first post-merge run was
|
|
||||||
green and had reconciled *nothing*: upstream's #209 restructure moved reconcile
|
|
||||||
out of the labels caller and behind a dispatch this forge cannot perform, so
|
|
||||||
the only job that ran was the refusal. Green, correct, and proof of the
|
|
||||||
refusal path only.
|
|
||||||
|
|
||||||
So before citing any run:
|
|
||||||
|
|
||||||
1. **Inventory what the sync changed about workflow triggers and jobs** — which
|
|
||||||
jobs exist now, which events fire them, and which of those this forge can
|
|
||||||
actually serve. A restructure upstream can move work between workflows
|
|
||||||
without touching a line of the code that does it.
|
|
||||||
2. **Read the run's executed steps**, not its status. Name the job that did the
|
|
||||||
thing, and quote the line that shows it did.
|
|
||||||
3. A green *skipped-or-refusing* path is valid evidence **for that path**, and
|
|
||||||
never evidence that the work happened.
|
|
||||||
|
|
||||||
Neither of these is caught by the no-runtime-`gh` scan in step 4: in this sync
|
|
||||||
both failures occurred with that guard green and CI green.
|
|
||||||
|
|
||||||
## Where the forge delta lives
|
|
||||||
|
|
||||||
Forge-specific behaviour is confined to the files below. Keeping it there is
|
|
||||||
what makes each sync cost 18 hunks instead of hundreds, and
|
|
||||||
`test/upstream-delta.test.sh` fails the PR that scatters it into a new file.
|
|
||||||
|
|
||||||
**What that guard actually checks**, stated precisely so the table is not read
|
|
||||||
as a stronger promise than it is: it walks every tracked file except prose
|
|
||||||
(`*.md`), the test harness and `changelog.d/`, and flags any that **decides**
|
|
||||||
the forge — the selector's verbs, `CEREMONY_FORGE*`, or a server-URL comparison
|
|
||||||
written inline. Discovery is derived from the tree rather than from a list of
|
|
||||||
directories and extensions, so a composite `action.yml` or a `.yaml` workflow
|
|
||||||
is seen without anyone remembering to add it.
|
|
||||||
|
|
||||||
It is a check on *forge decisions in executable and configuration files*. It is
|
|
||||||
**not** a diff against upstream, so it cannot see a file that differs from
|
|
||||||
upstream for some other forge-specific reason — `drills/` and
|
|
||||||
`.github/labels.conf` are in the table for that kind of reason and are listed
|
|
||||||
by judgement, not by scan.
|
|
||||||
|
|
||||||
| file | what is forge-specific about it |
|
|
||||||
|---|---|
|
|
||||||
| `lib/forge.sh` | the selector: `forge_detect`, `forge_client`, `forge_preflight` |
|
|
||||||
| `lib/forge-github.sh` | the gh backend — the one file allowed to speak `gh` |
|
|
||||||
| `lib/forge-forgejo.sh` | the Forgejo backend, `/api/v1` over curl + jq |
|
|
||||||
| `lib/closes_references.sh` | the closing-keyword parser that replaced GraphQL |
|
|
||||||
| `.github/labels.conf` | this instance's roster |
|
|
||||||
| `drills/` | records of runs on this instance |
|
|
||||||
| `actions/refs-not-closing/run.sh` | declares `CEREMONY_FORGE_CLIENT=gh` — its gather is GraphQL, which Forgejo does not serve. #199 removes the declaration |
|
|
||||||
| `.github/workflows/labels.yml` | the sweep dispatch decides the forge inline and declares a client; a workflow has no shell to call `forge_preflight` from. #205 ports it |
|
|
||||||
| `.github/workflows/refs-guard.yml` | schedules its job on GitHub only, so an action that can only refuse here does not stand red. #199 removes the gate |
|
|
||||||
| `.github/workflows/release-exercise.yml` | pins `CEREMONY_FORGE: github` deliberately: the exercise drives the GitHub path |
|
|
||||||
| `actions/docs-sync/docs-sync.sh` | fetches the doctrine mirror from the forge in `GITHUB_SERVER_URL`, and refuses rather than guessing one (#201) |
|
|
||||||
|
|
||||||
Four of those are **temporary** and say which issue removes them. That is the
|
|
||||||
point of listing them rather than exempting them: a forge-delta location with
|
|
||||||
no exit is indistinguishable from one nobody noticed.
|
|
||||||
|
|
||||||
A file that merely **calls** the shim is not a delta location — every
|
|
||||||
reconciler and `release.yml` call `forge_select`, and that is what the shim is
|
|
||||||
for. A file that **decides** or **declares** is, and belongs here.
|
|
||||||
|
|
||||||
If a sync needs forge branching somewhere else, that is a design decision, not
|
|
||||||
a detail: add the file to the inventory in the same PR, with the reason.
|
|
||||||
|
|
@ -3,4 +3,3 @@ TRIAGE.md
|
||||||
BUILDER.md
|
BUILDER.md
|
||||||
REVIEWER.md
|
REVIEWER.md
|
||||||
LABELS.md
|
LABELS.md
|
||||||
RELEASES.md
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# 0.4.0 — drill record
|
|
||||||
|
|
||||||
Run 2026-07-29 by `codex-bot-andresmgsl` against release PR #207 head
|
|
||||||
`7c755bcd402ba7f9a38ecd406a025c149c77aa57`.
|
|
||||||
|
|
||||||
Where: disposable private repo
|
|
||||||
`codex-bot-andresmgsl/ceremony-drill-0.4.0`, carrying the
|
|
||||||
`docs/CONSUMERS.md` release caller and a fragment-mode fixture armed at
|
|
||||||
`0.4.0-dev`. The fixture had `changelog.d/README.md`, one release fragment,
|
|
||||||
and a non-blank drill record. The repository is archived, pending the
|
|
||||||
operator's delete.
|
|
||||||
|
|
||||||
## Candidate-ref deviation
|
|
||||||
|
|
||||||
The pure consumer path cannot resolve `CEREMONY_SELF_REF: "0.4.0"` before
|
|
||||||
the candidate creates that tag. No `0.4.0` branch was created in
|
|
||||||
`heavy-duty/ceremony`. The scratch caller instead used
|
|
||||||
`codex-bot-andresmgsl/ceremony@drill/0.4.0`, whose parent is PR #207 head
|
|
||||||
`7c755bcd402ba7f9a38ecd406a025c149c77aa57` and whose only additional
|
|
||||||
commit rewrites both `CEREMONY_SELF_REF` carriers to that same canonical
|
|
||||||
candidate SHA. All runtime machinery was therefore fetched from the 0.4.0
|
|
||||||
candidate tree.
|
|
||||||
|
|
||||||
## Probes
|
|
||||||
|
|
||||||
| # | probe | run | result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | merge-door ceremony | 30445585532 (attempt 1) | ✅ one `0.4.0` release; tag equals merge commit; main re-armed to `0.4.1-dev` with only `changelog.d/README.md` |
|
|
||||||
| 2 | mislabeled ordinary PR | 30445473977 | ✅ green NOTICE no-op; no tag or release |
|
|
||||||
| 3 | bare-version PR without `release` | 30445513860 | ✅ refused at decide; no tag or release |
|
|
||||||
| 4 | re-run completed ceremony | 30445585532 (attempt 2) | ✅ refused because the tag/release already existed; release count stayed one |
|
|
||||||
| 5 | manual matching tag | 30445658952 | ✅ `0.5.0` published with its changelog section; main untouched |
|
|
||||||
| 6 | mismatched tag | 30445684268 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterward |
|
|
||||||
|
|
||||||
The merge-door `0.4.0` tag and PR #3 merge commit were both
|
|
||||||
`2b2d592ffcc5c376e55bd5fcf2dd5ffbdd692d64`. Its release body was exactly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
- Fragment mode is exercised by the 0.4.0 drill.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
The armed fixture was committed before the caller so the first workflow run
|
|
||||||
had a real parent version to inspect. Installing the caller then produced
|
|
||||||
green baseline run 30445432039.
|
|
||||||
|
|
||||||
After the unlabeled bare-version refusal, its merge commit was reverted to
|
|
||||||
restore the armed fixture. That setup correction produced green no-op run
|
|
||||||
30445544068 before the merge-door probe began.
|
|
||||||
125
drills/0.4.1.md
125
drills/0.4.1.md
|
|
@ -1,125 +0,0 @@
|
||||||
# 0.4.1 — drill record
|
|
||||||
|
|
||||||
Two runs. The first, 2026-08-04 against release PR !190 head `9a229ee`,
|
|
||||||
**failed**: both doors were inoperable and the release could not publish at
|
|
||||||
all. The second, after #191 landed as `fda5657`, **passed**. Both are
|
|
||||||
recorded, because the first is why the second exists.
|
|
||||||
|
|
||||||
## Run 2 — against merged `main` `fda5657` (the one that counts)
|
|
||||||
|
|
||||||
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1-final`,
|
|
||||||
armed at `0.4.1-dev`, carrying the `docs/CONSUMERS.md` release caller, a
|
|
||||||
fragment-mode fixture, and — unlike run 1 — an **artifact hook** dropping two
|
|
||||||
deliberately awkward filenames, `drill asset.tgz` and `a&b.tgz`. Archived at
|
|
||||||
the end; the operator's delete is pending, and cleanup gates nothing.
|
|
||||||
|
|
||||||
Candidate ref: `cluade-reviewer-andresmgsl/ceremony@drill-main`, parent
|
|
||||||
`fda5657`, whose only extra commit rewrites both `CEREMONY_SELF_REF`
|
|
||||||
carriers to that SHA — `release.yml`'s self-checkout is hardcoded to
|
|
||||||
`heavy-duty/ceremony`, so only a SHA that resolves there can stand in for a
|
|
||||||
tag that does not exist yet. No `0.4.1` branch was created on
|
|
||||||
`heavy-duty/ceremony`.
|
|
||||||
|
|
||||||
| # | probe | run | result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | merge-door ceremony | 637 | ✅ exactly one release `0.4.1`; body is the version's own changelog section; tag and release on merge commit `4a83fa1b`; **main re-armed to `0.4.2-dev`**; both assets uploaded |
|
|
||||||
| 2 | mislabeled ordinary PR | 652 | ✅ green no-op in **7s** on merge commit `e71df4e3` — release count stayed **2**, no tag created |
|
|
||||||
| 3 | bare version, no `release` label | 638 | ✅ refused at `b091aff2` — release count stayed **1** |
|
|
||||||
| 4 | re-run of the completed ceremony | 654 | ✅ refused in **8s** on merge commit `82e7d11b` — release count stayed **2**, and tag `0.4.1` **stayed on `4a83fa1b`**, the original merge commit |
|
|
||||||
| 5 | tag door, matching tag | 639 | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** |
|
|
||||||
| 6 | mismatched tag | 640 | ✅ `9.9.9` refused — release count stayed **2**; the tag exists, the release does not |
|
|
||||||
|
|
||||||
Probes 2 and 4 were run last, at 15:23–15:28Z, on the same consumer: it was
|
|
||||||
un-archived for them and archived again at the end. Nothing else about the
|
|
||||||
run changed — same candidate ref, same caller pin.
|
|
||||||
|
|
||||||
**Probe 4 diverges in mechanism, not in what it proves.** The 0.3.0 and 0.4.0
|
|
||||||
siblings re-ran the completed ceremony's workflow run (GitHub's "attempt 2").
|
|
||||||
Forgejo 8.0.3 exposes no run-rerun API — there are no `actions/runs/{id}`
|
|
||||||
routes in its swagger at all — so the ceremony was re-run by reproducing its
|
|
||||||
input instead: main re-armed to `0.4.1-dev` (setup run 653), then a second
|
|
||||||
`release`-labeled PR stamping bare `0.4.1` merged on top. That is the same
|
|
||||||
state the door refuses on, reached by a push rather than a re-trigger, and it
|
|
||||||
is stricter than the sibling shape in one way — it re-enters through
|
|
||||||
`facts` → `decide` rather than replaying a decided run.
|
|
||||||
|
|
||||||
Which refusal fired is measurable even without run logs. Replaying the
|
|
||||||
door's own inputs against the live consumer at `82e7d11b`:
|
|
||||||
|
|
||||||
```
|
|
||||||
facts: ver='0.4.1' base_ver='0.4.1-dev' released='' labeled='yes'
|
|
||||||
decide: ceremony=yes
|
|
||||||
```
|
|
||||||
|
|
||||||
So `decide` said **go** — a labeled bare transition is row 6 — and the stop
|
|
||||||
came from the merge door's own pre-publish assert, `release.yml:216-219`,
|
|
||||||
whose comment names this exact probe ("what makes a re-run of a completed
|
|
||||||
ceremony refuse instead of clobber"): the tag existed, so it refused before
|
|
||||||
`forge_tag_create` ever ran. The second net behind it is #191's own verb,
|
|
||||||
and it reads this consumer correctly:
|
|
||||||
|
|
||||||
```
|
|
||||||
forge_release_exists 0.4.1 -> yes 0.5.0 -> yes 9.9.9 -> no
|
|
||||||
```
|
|
||||||
|
|
||||||
`9.9.9` is the probe-6 receipt in the same breath: the tag exists, the
|
|
||||||
release does not.
|
|
||||||
|
|
||||||
Evidence for every probe here is the run conclusion plus the repository
|
|
||||||
state before and after — this instance serves no run logs (the API 404s on
|
|
||||||
every log route), so no probe's result is quoted from a log line.
|
|
||||||
|
|
||||||
End state, as observed when this was written: releases `0.4.1` and `0.5.0`
|
|
||||||
and nothing else; tags `0.4.1` on `4a83fa1b`, `0.5.0` and `9.9.9` on
|
|
||||||
`b091aff2`; the consumer's `main` left at bare `0.4.1` where probe 4 stopped
|
|
||||||
it, private and archived.
|
|
||||||
|
|
||||||
**The asset names survived intact**: `a&b.tgz` and `drill asset.tgz` both
|
|
||||||
appear under those exact names on both releases. Before #191's fix the space
|
|
||||||
made curl reject the URL outright and the `&` split the query — the failure
|
|
||||||
landing *after* the tag exists, mid-publish, which is the worst place this
|
|
||||||
door has.
|
|
||||||
|
|
||||||
## Run 1 — against `9a229ee`, before the fix (FAILED)
|
|
||||||
|
|
||||||
Recorded because the failure is the reason #191 exists — including its
|
|
||||||
deviations, which no later success retires.
|
|
||||||
|
|
||||||
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1` — a
|
|
||||||
different consumer from run 2's, archived at the end with the operator's
|
|
||||||
delete pending. Candidate ref
|
|
||||||
`cluade-reviewer-andresmgsl/ceremony@drill-0.4.1` (`f148255`), parent
|
|
||||||
`9a229ee`, its only extra commit rewriting both `CEREMONY_SELF_REF` carriers
|
|
||||||
to that SHA.
|
|
||||||
|
|
||||||
**Deviation, disclosed and not retired by run 2:** run 1's scratch repo was
|
|
||||||
flipped **public for roughly 8 minutes** to read job logs — Forgejo's web log
|
|
||||||
route 404s for a token-authenticated private repo and the `/api/v1` log
|
|
||||||
routes 404 outright — then restored to private and archived. That is a real
|
|
||||||
departure from `drills/README.md`'s "scratch **private** repo", and it stays
|
|
||||||
in the record. Run 2 did not repeat it: it read no logs at all, which is why
|
|
||||||
every run-2 row is a repository-state measurement.
|
|
||||||
|
|
||||||
| # | probe | run | result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | merge-door ceremony | 12 (and 7, at `0.4.1`) | ❌ **FAILED** — `decide` refused; no tag, no release, main left bare |
|
|
||||||
| 2 | `-dev` push no-op | 9, and the fixture push | ✅ green no-op, nothing created |
|
|
||||||
| 3 | bare version, no `release` label | — | ⚠️ not run independently; probe 1 refused through exactly this path, because the label fact read `no` |
|
|
||||||
| 4 | re-run of a completed ceremony | — | ⚠️ **not reachable** — no ceremony ever completed to re-run |
|
|
||||||
| 5 | tag door | 14 | ❌ **FAILED** — `gh: command not found` at *publish the release*; no release |
|
|
||||||
| 6 | mismatched tag | 16 | ✅ `tag '9.9.9' does not match the tree's version '0.4.2' — creating nothing` |
|
|
||||||
|
|
||||||
Root cause, both doors: `lib/facts.sh` and `release.yml` spoke `gh`, which
|
|
||||||
the runner image does not ship. `facts.sh` read *any* failure as a definite
|
|
||||||
`no`, so a missing binary silently demoted a release ceremony to "a bare
|
|
||||||
push". Release count across the whole of run 1: **0**.
|
|
||||||
|
|
||||||
## What changed between them
|
|
||||||
|
|
||||||
#191, merged as `fda5657`: both doors onto `lib/forge.sh`; a read that did
|
|
||||||
not complete refuses instead of fabricating a `no`; `forgejo_api_base`
|
|
||||||
refuses an empty `REPO` so `repos//…` cannot be addressed; release asset
|
|
||||||
names percent-encoded.
|
|
||||||
|
|
||||||
Every refusal path, in both runs, created nothing. That property never
|
|
||||||
broke — what broke was the doors' ability to say yes.
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# 0.5.0 — drill record
|
|
||||||
|
|
||||||
Run 2026-08-03 by `dan-claude-bot` against the release PR (Refs #233),
|
|
||||||
candidate branch `build/233-release-0-5-0` on `heavy-duty/ceremony` main at
|
|
||||||
`0ac3a6f`.
|
|
||||||
|
|
||||||
## Scope ruling — doors unchanged, no disposable-repo rehearsal
|
|
||||||
|
|
||||||
The 0.4.0 drill (drills/0.4.0.md) probed both release doors end-to-end in a
|
|
||||||
disposable repo; the live 0.4.0 and 0.4.1 releases then exercised the merge
|
|
||||||
door for real, twice, within the last three days. Measured at `0ac3a6f`,
|
|
||||||
stated as they are rather than as #233's D3 predicted them at `b18c0bc`:
|
|
||||||
|
|
||||||
- `git diff 0.4.1..main -- .github/workflows/release.yml` — **empty**. No
|
|
||||||
door logic, no decide table, no publish step moved.
|
|
||||||
- `git diff 0.4.1..main -- bin` — **empty**. No release tooling moved.
|
|
||||||
- `git diff 0.4.1..main -- lib` — **not empty**: `lib/ruling.sh` +50/−13,
|
|
||||||
#226's best-shaped escalation selection, merged this morning as PR #234.
|
|
||||||
That is the labels-sweep library the reconcilers source; nothing in the
|
|
||||||
release path reads it. The doors-unchanged ruling rests on the two empty
|
|
||||||
surfaces above, not on a lib claim this tag can no longer make.
|
|
||||||
|
|
||||||
Re-running a full disposable-repo drill would rehearse machinery this repo
|
|
||||||
proved in rehearsal at 0.4.0 and in production at both subsequent tags. This
|
|
||||||
record rests on the standing evidence below and says so honestly. The panel
|
|
||||||
reviews this claim like any other; if any reviewer rules a full drill owed,
|
|
||||||
that verdict wins (#233 D3).
|
|
||||||
|
|
||||||
## Standing evidence at the candidate head
|
|
||||||
|
|
||||||
| # | probe | where | result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | decide + merge-door step-replay (dogfood) | `release-exercise / step-replay (dogfood)` on this PR | CI-gating; green required to merge |
|
|
||||||
| 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge |
|
|
||||||
| 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge |
|
|
||||||
| 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge |
|
|
||||||
| 5 | the 0.5.0 payload's one lib delta: #226's best-shaped selection | `test/ruling.test.sh` in the `test` job at this head, plus claude-bot's independent reproduction in the #234 review round | green — suite coverage, **not** live dogfood; see below |
|
|
||||||
|
|
||||||
Probe 5 makes no live-exercise claim, and the reason is stated so the next
|
|
||||||
reader does not restore one: the post-merge sweeps (`30811983604`,
|
|
||||||
`30812172095`, both at `0ac3a6f`) do run the candidate's `lib/ruling.sh` on
|
|
||||||
the dogfood path, but every #226 call site sits inside `reconcile_ruling`,
|
|
||||||
whose single caller is behind the `needs-ruling` flag check
|
|
||||||
(`labels-reconcile.sh:865–866`), and this board has no `needs-ruling` item,
|
|
||||||
standing or historical — so not one line of the delta this tag carries has
|
|
||||||
executed live here. Its coverage at this candidate is `test/ruling.test.sh`
|
|
||||||
(107 checks, including the crew#293 replay and the six regression proofs
|
|
||||||
named in PR #234) and the #234 round's independent re-run of that proof.
|
|
||||||
Per drills/README.md — the record is the evidence — and #135's defect
|
|
||||||
class, a smaller true probe stands where a larger unobserved one would not.
|
|
||||||
|
|
||||||
## Deviations
|
|
||||||
|
|
||||||
No candidate-ref deviation arises: this record stages no scratch caller, so
|
|
||||||
nothing needs to resolve `CEREMONY_SELF_REF: "0.5.0"` before the tag exists.
|
|
||||||
The sibling adoption proof is post-tag and crew's: crew#298's pin bump, then
|
|
||||||
its `panel[<login>]=` rows, then crew's first clean sweep — owned by that
|
|
||||||
issue's criteria, reported back on #233 per its post-merge criterion.
|
|
||||||
272
drills/0.6.0.md
272
drills/0.6.0.md
|
|
@ -1,272 +0,0 @@
|
||||||
# 0.6.0 — drill record
|
|
||||||
|
|
||||||
Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249),
|
|
||||||
candidate branch `build/249-release-0-6-0`, canonical candidate SHA
|
|
||||||
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`. All six probes ran; every row in
|
|
||||||
the table below was written from its own run.
|
|
||||||
|
|
||||||
## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused
|
|
||||||
|
|
||||||
This record's shape was measured, not chosen. `drills/README.md` allows the
|
|
||||||
doors-unchanged shape only when all three of its conditions hold at the
|
|
||||||
candidate head; the first one does not.
|
|
||||||
|
|
||||||
The baseline is the last **rehearsed** tag, never the previous tag:
|
|
||||||
`drills/0.4.1.md` and `drills/0.5.0.md` are both doors-unchanged records, so
|
|
||||||
the anchor is **`0.4.0`**, whose record is a full disposable-repo rehearsal,
|
|
||||||
whose release is published, and after which `main` was re-armed to
|
|
||||||
`0.4.1-dev` (`84bb1a4`). Condition 3 holds.
|
|
||||||
|
|
||||||
The release path is exactly the output of `.github/scripts/release-path.sh`
|
|
||||||
at this head — `.github/workflows/release.yml`, `bin/`, `lib/version.sh`,
|
|
||||||
`lib/decide.sh`, `lib/facts.sh`, `lib/changelog.sh`. Condition 2 holds.
|
|
||||||
|
|
||||||
Condition 1 fails. Measured at this candidate:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ git diff 0.4.0..HEAD -- $(.github/scripts/release-path.sh)
|
|
||||||
.github/workflows/release.yml | 2 +-
|
|
||||||
lib/changelog.sh | 83 ++++++++++++++++++++++++++++++++++++++---
|
|
||||||
```
|
|
||||||
|
|
||||||
`release.yml`'s two lines are the `CEREMONY_SELF_REF` pin, which the
|
|
||||||
condition exempts. **`lib/changelog.sh` is not exempt and is not empty**: it
|
|
||||||
carries `72fa3e0` (the terminal issue-citation rule joining the fragment
|
|
||||||
guard, #262) and `75a5b68` (one fragment, one diagnosis, #262). That file is
|
|
||||||
on the release path because the merge door sources it to assemble and read
|
|
||||||
the release section — this is a door byte, not a neighbouring library, and
|
|
||||||
the last-rehearsed anchor exists precisely so an accumulated change like
|
|
||||||
this forces a new rehearsal rather than chaining a third doors-unchanged
|
|
||||||
assertion off the second.
|
|
||||||
|
|
||||||
So this release owes the disposable-repo rehearsal, and this record is it.
|
|
||||||
|
|
||||||
## Where
|
|
||||||
|
|
||||||
Disposable **private** repo `cndgrr/ceremony-drill-0.6.0`, created
|
|
||||||
2026-08-05T00:02:58Z. It carries the `docs/CONSUMERS.md` release caller
|
|
||||||
verbatim (`version-source: file`) over a fragment-mode fixture armed at
|
|
||||||
`0.6.0-dev`: a preamble-only `CHANGELOG.md`, `changelog.d/README.md` plus
|
|
||||||
one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was
|
|
||||||
created there before the first ceremony PR, per the guide's prerequisite.
|
|
||||||
|
|
||||||
**Disposal, as this record's author observed it**: the repository is
|
|
||||||
**archived** — `PATCH /repos/cndgrr/ceremony-drill-0.6.0` with
|
|
||||||
`archived: true` returned `true`, and a fresh read afterwards reported
|
|
||||||
`archived=true private=true`. It is **pending the operator's delete**, which
|
|
||||||
this builder cannot perform: `delete_repo` is absent from fleet tokens by
|
|
||||||
doctrine (#135). No delete was attempted and none is claimed. Cleanup gates
|
|
||||||
nothing — not this PR's ready-for-review, not the panel, not the merge.
|
|
||||||
|
|
||||||
## Candidate-ref deviation
|
|
||||||
|
|
||||||
The pure consumer path cannot resolve this candidate's
|
|
||||||
`CEREMONY_SELF_REF: "0.6.0"`: that tag is the one this release has not
|
|
||||||
created yet. No `0.6.0` branch was created on `heavy-duty/ceremony`.
|
|
||||||
|
|
||||||
The scratch caller instead pins `cndgrr/ceremony/.github/workflows/release.yml@drill/0.6.0`.
|
|
||||||
That fork ref's parent is the canonical candidate SHA
|
|
||||||
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`, and its one additional commit
|
|
||||||
(`775b4d1f6485ebdde924979ac2dce536643c6071`) rewrites all three
|
|
||||||
`CEREMONY_SELF_REF` carriers — `release.yml`, `labels.yml`,
|
|
||||||
`labels-sweep.yml` — to that same SHA. All runtime machinery in every probe
|
|
||||||
below was therefore fetched from the 0.6.0 candidate tree.
|
|
||||||
|
|
||||||
Commits pushed to the candidate after `fb8f828` are this record only; the
|
|
||||||
release path (`.github/scripts/release-path.sh`) is byte-identical at the
|
|
||||||
canonical SHA and at the final head.
|
|
||||||
|
|
||||||
## Probes
|
|
||||||
|
|
||||||
One row per probe, written from its run. Runs are in
|
|
||||||
`cndgrr/ceremony-drill-0.6.0`.
|
|
||||||
|
|
||||||
| # | probe | run | result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | merge-door ceremony | 30992108742 (attempt 1) | ✅ exactly one `0.6.0` release; tag equals the merge commit; main re-armed to `0.6.1-dev` |
|
|
||||||
| 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release |
|
|
||||||
| 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release |
|
|
||||||
| 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one |
|
|
||||||
| 5 | manual matching tag | 30992258952 | ✅ `0.6.1` published from its own changelog section; main untouched |
|
|
||||||
| 6 | mismatched tag | 30992310031 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterwards |
|
|
||||||
|
|
||||||
### Probe 5 — a manual tag matching its tree
|
|
||||||
|
|
||||||
Branch `probe5-tag` carried `VERSION` at `0.6.1` and a
|
|
||||||
`## 0.6.1 — 2026-08-05` section; tag `0.6.1` was pushed at that commit
|
|
||||||
(`dfd0cfeaca772cf45bcb63a1a639829185510c60`) with a personal token, so it
|
|
||||||
fired the door — the anti-recursion property probe 1 relies on is exactly
|
|
||||||
what makes a hand-pushed tag the only way to reach this door. The
|
|
||||||
`release-on-merge` job skipped and `release-on-tag` ran: the version assert
|
|
||||||
passed, notes were extracted, the release published.
|
|
||||||
|
|
||||||
The branch, not main, carried the tagged tree on purpose — the tag door
|
|
||||||
takes no bump step, and pointing it at a side branch proves that without a
|
|
||||||
bare version ever sitting on main. Observed afterwards: `0.6.1` published
|
|
||||||
with exactly its own section's bullet, and main still reading `0.6.1-dev`,
|
|
||||||
untouched by the publish. Two releases now exist, `0.6.0` and `0.6.1`,
|
|
||||||
neither a draft, neither carrying assets.
|
|
||||||
|
|
||||||
### Probe 6 — a mismatched tag
|
|
||||||
|
|
||||||
Tag `9.9.9` was pushed at the same `0.6.1` commit. The door refused at its
|
|
||||||
first assert, before notes and before publication:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tag '9.9.9' does not match the tree's version '0.6.1' — creating nothing.
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes, the artifact hook and publish all skipped. `GET /releases` still
|
|
||||||
returned exactly `0.6.1` and `0.6.0`. The `9.9.9` ref was deleted afterwards
|
|
||||||
(`DELETE /git/refs/tags/9.9.9`); `GET /git/refs/tags` then listed `0.6.0`
|
|
||||||
and `0.6.1` only. The probe tag was the operator's artefact, never the
|
|
||||||
workflow's — the door created nothing, which is the whole assertion.
|
|
||||||
|
|
||||||
### Probe 1 — the merge-door ceremony
|
|
||||||
|
|
||||||
PR #4 (`probe1-ceremony`) bumped `0.6.0-dev` to bare `0.6.0` and stamped
|
|
||||||
`## 0.6.0 — 2026-08-05`, assembled from the three fixture fragments by the
|
|
||||||
candidate's own `bin/changelog-assemble` and committed with the deletions
|
|
||||||
in one commit. The `release` label was applied and confirmed before the
|
|
||||||
merge. Facts and verdict:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VER: 0.6.0
|
|
||||||
BASE_VER: 0.6.0-dev
|
|
||||||
RELEASED:
|
|
||||||
LABELED: yes
|
|
||||||
ceremony=yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed after the run:
|
|
||||||
|
|
||||||
- **Exactly one** release: `GET /releases` returned `0.6.0` alone, not a
|
|
||||||
draft, not a pre-release, zero assets (no artifact hook in the fixture —
|
|
||||||
the hook step skipped).
|
|
||||||
- `GET /tags` returned `0.6.0` alone, pointing at
|
|
||||||
`64d02539f4a20286afc08b9997f0f8a7d1dbfccd`, which is PR #4's merge commit
|
|
||||||
— the tag names the tree that was reviewed.
|
|
||||||
- The release body was byte-for-byte the assembled section's bullets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
- A second ordinary fragment, written by probe 2 of the 0.6.0 drill (#249).
|
|
||||||
- An ordinary behavior change, landing under the release label (#249).
|
|
||||||
- Fragment mode is exercised by the ceremony 0.6.0 drill (#249).
|
|
||||||
```
|
|
||||||
|
|
||||||
- Main re-armed itself: commit `2d0e19a` ("bump main to 0.6.1-dev — a dev
|
|
||||||
install must not impersonate 0.6.0"), pushed by the job's own token. Main
|
|
||||||
reads `0.6.1-dev` and `changelog.d/` holds only `README.md`.
|
|
||||||
- **The anti-recursion property held.** Neither the tag create nor the bump
|
|
||||||
push started a workflow run — the run list after the ceremony ends at
|
|
||||||
30992108742. That is what makes the merge door the release's only chance
|
|
||||||
to publish, and it is the reason probe 4 below is the door's own guard
|
|
||||||
rather than a second run's.
|
|
||||||
|
|
||||||
### Probe 4 — a re-run of the completed ceremony
|
|
||||||
|
|
||||||
Re-running 30992108742 as attempt 2 re-decided `ceremony=yes` — the facts
|
|
||||||
at that merge commit have not changed — and then died at the assert:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tag '0.6.0' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
|
|
||||||
```
|
|
||||||
|
|
||||||
Tag, publish and bump all skipped. `GET /releases` still returned exactly
|
|
||||||
one `0.6.0`. The refusal is loud (the job is red) and creates nothing, which
|
|
||||||
is the required shape: the assert is what covers a manual tag racing the
|
|
||||||
merge, not only an operator's stray re-run.
|
|
||||||
|
|
||||||
### Probe 3 — a bare-version PR without the `release` label
|
|
||||||
|
|
||||||
PR #3 (`probe3-bare`) bumped `VERSION` to bare `0.6.0` and carried no label;
|
|
||||||
the label list was read as empty before merging. The merge run refused at
|
|
||||||
decide, row 5 of the table:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VER: 0.6.0
|
|
||||||
BASE_VER: 0.6.0-dev
|
|
||||||
RELEASED:
|
|
||||||
LABELED: no
|
|
||||||
the version transitioned ('0.6.0-dev' -> '0.6.0') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes, the assert, tag, hook, publish and bump all skipped; tags and
|
|
||||||
releases were both still empty afterwards. The merge was then undone and
|
|
||||||
main re-armed to `0.6.0-dev` before the ceremony probe ran (see Setup).
|
|
||||||
|
|
||||||
### Probe 2 — a mislabeled ordinary PR
|
|
||||||
|
|
||||||
PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no
|
|
||||||
version. The `release` label was applied through
|
|
||||||
`POST /repos/{owner}/{repo}/issues/2/labels` and confirmed present before
|
|
||||||
the merge. The merge run decided row 1 of the table and published nothing:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VER: 0.6.0-dev
|
|
||||||
BASE_VER: 0.6.0-dev
|
|
||||||
RELEASED:
|
|
||||||
LABELED:
|
|
||||||
NOTICE: the version '0.6.0-dev' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish.
|
|
||||||
ceremony=no
|
|
||||||
```
|
|
||||||
|
|
||||||
`RELEASED` and `LABELED` are empty on purpose — the `-dev` rows never
|
|
||||||
consult them, which is precisely why the label alone cannot ship anything.
|
|
||||||
Notes, the nothing-exists assert, tag, artifact hook, publish and bump all
|
|
||||||
skipped; `GET /tags` and `GET /releases` were both empty afterwards.
|
|
||||||
|
|
||||||
An earlier merge (PR #1, run 30991571096) was intended as this probe but
|
|
||||||
landed **unlabeled**: `gh pr edit --add-label` failed against this repo's
|
|
||||||
projects-classic GraphQL surface, and the merge went ahead before the
|
|
||||||
failure was read. That run is a green no-op too, but it is not evidence for
|
|
||||||
this probe — an unlabeled ordinary merge proves less than a labeled one —
|
|
||||||
so the probe was re-run as PR #2 with the label applied through the REST
|
|
||||||
endpoint and verified before merging. Recorded here because the run exists
|
|
||||||
in the repo's history and a reader will find it.
|
|
||||||
|
|
||||||
## Setup, and the runs that are not probes
|
|
||||||
|
|
||||||
The armed fixture was committed before the caller, so the first door run had
|
|
||||||
a real parent version to inspect: run **30962040469** is that green baseline
|
|
||||||
no-op. The probes then ran in the order 2, 3, 1, 4, 5, 6 — the refusals
|
|
||||||
first, against an armed tree, so the ceremony itself ran last against a
|
|
||||||
fixture the refusals had already proven intact.
|
|
||||||
|
|
||||||
Three non-probe runs are on the board and are accounted for here rather than
|
|
||||||
left for a reader to guess at:
|
|
||||||
|
|
||||||
- **30991571096** (green) — PR #1, the unlabeled first attempt at probe 2,
|
|
||||||
described above.
|
|
||||||
- **30991892212** (green) — restoring `VERSION` to `0.6.0-dev` after probe
|
|
||||||
3's refusal, so the ceremony probe met an armed tree. Row 2 of the table:
|
|
||||||
the version changed and still ends `-dev`.
|
|
||||||
- **30991958967** (red) — **a builder error, not a door finding.** An
|
|
||||||
uncommitted `VERSION` bump left over from staging the ceremony branch rode
|
|
||||||
along into a setup commit that was meant to touch only the fragments, and
|
|
||||||
pushed bare `0.6.0` straight to main. The door refused it exactly as it
|
|
||||||
refused probe 3, by the same row-5 path, and created nothing: tags and
|
|
||||||
releases were both still empty when the failure was read. Main was re-armed
|
|
||||||
to `0.6.0-dev` (green run **30992046247**) before the ceremony probe. It is
|
|
||||||
written down because a red run on a drill repo that the record does not
|
|
||||||
explain is indistinguishable from a door that failed.
|
|
||||||
|
|
||||||
The fixture's three fragments were also rewritten mid-setup to carry
|
|
||||||
terminal issue citations. The candidate's own `bin/changelog-assemble`
|
|
||||||
refused them without one — `fragment 'changelog.d/1.md' has an entry with no
|
|
||||||
issue citation` — which is #262's rule, one of the two commits on
|
|
||||||
`lib/changelog.sh` that make this release owe a rehearsal at all. The
|
|
||||||
fixture had been written before that rule existed. The refusal is the guard
|
|
||||||
working; the correction is recorded because the fragments the ceremony
|
|
||||||
consumed are not the fragments the repo was created with.
|
|
||||||
|
|
||||||
## What the rehearsal establishes
|
|
||||||
|
|
||||||
Both doors ran live against the 0.6.0 candidate's own machinery. The merge
|
|
||||||
door published exactly one release from a labeled ceremony PR, tagged the
|
|
||||||
reviewed merge commit, and re-armed main itself; it refused a bare push
|
|
||||||
without a label, refused a re-run of its own completed ceremony, and stayed
|
|
||||||
a green no-op under a label carried by ordinary work. The tag door published
|
|
||||||
from a matching manual tag without touching main, and refused a mismatched
|
|
||||||
one before creating anything. Every refusal created nothing — no tag, no
|
|
||||||
release, on any of the four refusal paths.
|
|
||||||
116
drills/0.6.1.md
116
drills/0.6.1.md
|
|
@ -1,116 +0,0 @@
|
||||||
# 0.6.1 — drill record
|
|
||||||
|
|
||||||
Run 2026-08-09 by `codex-reviewer-andresmgsl` against release PR !226,
|
|
||||||
candidate branch `release-0.6.1`, canonical candidate SHA
|
|
||||||
`ba3b17af88e0fe1ccae9eefd4b37bf2666f6cdad`. All six probes ran on this
|
|
||||||
Forgejo instance. Each result below was read from its own run and from the
|
|
||||||
repository state after that run.
|
|
||||||
|
|
||||||
## Scope ruling — a full rehearsal was owed
|
|
||||||
|
|
||||||
The last rehearsed tag was `0.6.0`. The release path at this candidate is the
|
|
||||||
exact output of `.github/scripts/release-path.sh`. Its measured diff from
|
|
||||||
`0.6.0` is not pin-only: `.github/workflows/release.yml`, `lib/facts.sh`, and
|
|
||||||
the new `lib/forge.sh` changed. These are release-door bytes, so the
|
|
||||||
doors-unchanged record shape is refused and this release carries a full live
|
|
||||||
rehearsal.
|
|
||||||
|
|
||||||
## Where
|
|
||||||
|
|
||||||
Disposable **private** repository `heavy-duty/ceremony-drill-0.6.1`, created
|
|
||||||
by the operator on 2026-08-09 after the fleet identity's personal repository
|
|
||||||
could not see the organization runner. It carries the `docs/CONSUMERS.md`
|
|
||||||
release caller with `version-source: file`, a fragment-mode fixture armed at
|
|
||||||
`0.6.1-dev`, and a non-blank `drills/0.6.1.md`. The `release` label existed
|
|
||||||
before the first probe PR.
|
|
||||||
|
|
||||||
**Disposal as observed when this record was written:** the repository is
|
|
||||||
private and **not yet archived**. This builder's archive PATCH returned 403
|
|
||||||
because it has push but not admin permission. The operator was asked to
|
|
||||||
archive it; delete remains the operator's later step. No archive or delete is
|
|
||||||
claimed, and cleanup gates neither review nor merge.
|
|
||||||
|
|
||||||
## Candidate-ref deviation
|
|
||||||
|
|
||||||
The candidate tree pins `CEREMONY_SELF_REF` to `0.6.1`, a tag that did not
|
|
||||||
exist before this release. No branch named `0.6.1` was created on
|
|
||||||
`heavy-duty/ceremony`.
|
|
||||||
|
|
||||||
The scratch caller instead uses
|
|
||||||
`codex-reviewer-andresmgsl/ceremony/.github/workflows/release.yml@drill/0.6.1`.
|
|
||||||
That ref is commit `f766752090429241c20a0d86aba6f679d713fc2c`; its parent is the canonical
|
|
||||||
candidate SHA above, and its only change rewrites all three
|
|
||||||
`CEREMONY_SELF_REF` carriers to that canonical SHA. The release path is
|
|
||||||
therefore byte-identical to the candidate except for the required candidate
|
|
||||||
pin rewrite.
|
|
||||||
|
|
||||||
## Probes
|
|
||||||
|
|
||||||
| # | probe | run | result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | merge-door ceremony | 5 | ✅ one `0.6.1` release; tag points to merge commit `438097fc75b1d294b448830a3a79c00b0ee7b83f`; main re-armed to `0.6.2-dev` |
|
|
||||||
| 2 | release-labelled ordinary PR | 2 | ✅ green no-op; zero tags and zero releases |
|
|
||||||
| 3 | bare-version PR without `release` | 3 | ✅ refused; zero tags and zero releases |
|
|
||||||
| 4 | completed ceremony repeated | 7 | ✅ refused; release count stayed one and tag `0.6.1` stayed on the original merge commit |
|
|
||||||
| 5 | manual matching tag | 8 | ✅ `0.6.2` published from its own changelog section; main was unchanged |
|
|
||||||
| 6 | mismatched tag | 9 | ✅ refused before publication; no `9.9.9` release, and the operator-created probe tag was removed afterwards |
|
|
||||||
|
|
||||||
### Probe 1 — merge door
|
|
||||||
|
|
||||||
PR #4 transitioned `VERSION` from `0.6.1-dev` to `0.6.1`, assembled both
|
|
||||||
fixture fragments, and carried the `release` label. Run 5 succeeded. The
|
|
||||||
published body is exactly the assembled `0.6.1` section, tag `0.6.1` points
|
|
||||||
to PR #4's merge commit, and the workflow pushed `0.6.2-dev` to main at
|
|
||||||
`1c74d76f035da4a13f7af0d2a43d611092061204`.
|
|
||||||
|
|
||||||
### Probe 2 — labelled ordinary work
|
|
||||||
|
|
||||||
PR #1 carried the `release` label and added only `changelog.d/2.md`.
|
|
||||||
`VERSION` stayed `0.6.1-dev`. Run 2 succeeded as a no-op; the tag and release
|
|
||||||
lists were both empty afterwards.
|
|
||||||
|
|
||||||
### Probe 3 — bare version without declared intent
|
|
||||||
|
|
||||||
PR #2 assembled the two fragments and transitioned to bare `0.6.1`, but had
|
|
||||||
no `release` label. Run 3 failed. Both tag and release lists remained empty.
|
|
||||||
Setup PR #3 then reverted that probe and restored the armed fixture; run 4
|
|
||||||
was green before the ceremony probe began.
|
|
||||||
|
|
||||||
### Probe 4 — completed ceremony repeated
|
|
||||||
|
|
||||||
Forgejo exposes no run-rerun API, so this probe reproduced the completed
|
|
||||||
door's input. Setup PR #5 restored `0.6.1-dev` and run 6 was green. Release-
|
|
||||||
labelled PR #6 transitioned to bare `0.6.1` again. Run 7 failed at the
|
|
||||||
already-existing tag guard. The release count stayed one and tag `0.6.1`
|
|
||||||
still pointed to the original ceremony merge commit, not PR #6.
|
|
||||||
|
|
||||||
### Probe 5 — matching manual tag
|
|
||||||
|
|
||||||
Side branch `probe5-tag` carried bare `VERSION 0.6.2` and a publishable
|
|
||||||
`0.6.2` changelog section. Tag `0.6.2` was created at
|
|
||||||
`a4ba62aa83fdd2e259ed3c3e906b13eaab1abcd0`. Run 8 succeeded and published
|
|
||||||
only that section. Main stayed at the state left by probe 4; the tag door did
|
|
||||||
not push a version change to it.
|
|
||||||
|
|
||||||
### Probe 6 — mismatched tag
|
|
||||||
|
|
||||||
Tag `9.9.9` was created at the same `0.6.2` tree. Run 9 failed before
|
|
||||||
publication. The release list remained exactly `0.6.1` and `0.6.2`. The
|
|
||||||
operator-created `9.9.9` ref was then deleted; the remaining tag list was
|
|
||||||
exactly `0.6.1` and `0.6.2`.
|
|
||||||
|
|
||||||
## Runs that are setup, not probes
|
|
||||||
|
|
||||||
- Run 1: the initial `0.6.1-dev` fixture push; green no-op.
|
|
||||||
- Run 4: PR #3 restored the armed fixture after probe 3; green.
|
|
||||||
- Run 6: PR #5 restored `0.6.1-dev` to reproduce the completed ceremony
|
|
||||||
input for probe 4; green.
|
|
||||||
|
|
||||||
## What this rehearsal establishes
|
|
||||||
|
|
||||||
Both release doors ran against the 0.6.1 candidate's Forgejo implementation.
|
|
||||||
The merge door published once, tagged the reviewed merge commit, and re-armed
|
|
||||||
main. It stayed a green no-op for labelled ordinary work, refused a bare
|
|
||||||
unlabelled transition, and refused a repeated ceremony. The tag door
|
|
||||||
published a matching tag without changing main and refused a mismatched tag
|
|
||||||
without publishing. Every refusal created no tag or release of its own.
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# 0.6.2 — drill record
|
|
||||||
|
|
||||||
Measured 2026-08-24 on release PR !250, candidate branch
|
|
||||||
`build/231-release-0-6-2`. The release-path measurement was taken at
|
|
||||||
`fdb7d7577b4b30c11f2db9b47f278e2a16783c8c`; this record is the only later
|
|
||||||
candidate-tree change and `drills/` is outside the release path.
|
|
||||||
|
|
||||||
## Scope ruling — doors unchanged, no disposable-repo rehearsal
|
|
||||||
|
|
||||||
The last rehearsed tag is `0.6.1`. All three doors-unchanged conditions in
|
|
||||||
`drills/README.md` hold at this candidate tree:
|
|
||||||
|
|
||||||
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes
|
|
||||||
only the `CEREMONY_SELF_REF` pin in `.github/workflows/release.yml`, from
|
|
||||||
`0.6.1` to `0.6.2`. No release-door logic, decision, fact gathering,
|
|
||||||
version handling, changelog handling, or publish step changed.
|
|
||||||
2. The measured release path is exactly the output of
|
|
||||||
`.github/scripts/release-path.sh`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.github/workflows/release.yml
|
|
||||||
bin/
|
|
||||||
lib/version.sh
|
|
||||||
lib/decide.sh
|
|
||||||
lib/facts.sh
|
|
||||||
lib/changelog.sh
|
|
||||||
lib/forge.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
3. `drills/0.6.1.md` records a full six-probe rehearsal. The Forgejo release
|
|
||||||
API reported `0.6.1` published at `2026-08-09T19:46:56Z`, neither draft nor
|
|
||||||
prerelease, and `origin/main:VERSION` read `0.6.2-dev`, confirming that main
|
|
||||||
was re-armed after that release.
|
|
||||||
|
|
||||||
A new disposable-repo rehearsal would therefore execute the same release-door
|
|
||||||
bytes as the full 0.6.1 rehearsal. This record asserts only the mechanically
|
|
||||||
checked doors-unchanged case; the release panel may still rule that a full
|
|
||||||
rehearsal is owed.
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# 0.6.3 — drill record
|
|
||||||
|
|
||||||
Measured 2026-08-26 on the `release-0.6.3` candidate branch, canonical
|
|
||||||
candidate SHA `03cb69d`. All three measurements below were taken at that head,
|
|
||||||
not copied from an earlier record.
|
|
||||||
|
|
||||||
## Scope ruling — doors unchanged, no disposable-repo rehearsal
|
|
||||||
|
|
||||||
**The last rehearsed tag is `0.6.1`, not `0.6.2`.** `drills/0.6.2.md` is itself
|
|
||||||
a doors-unchanged assertion, and `drills/README.md` anchors the baseline to the
|
|
||||||
last *rehearsed* tag precisely so one such assertion cannot chain from another
|
|
||||||
while the doors drift a small diff at a time. The baseline used here is
|
|
||||||
therefore `0.6.1`, which widens the measured window rather than narrowing it.
|
|
||||||
|
|
||||||
All three conditions hold at the candidate head:
|
|
||||||
|
|
||||||
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes only
|
|
||||||
the `CEREMONY_SELF_REF` pin line in `.github/workflows/release.yml`, from
|
|
||||||
`0.6.1` to `0.6.3`:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
@@ -129,7 +129,7 @@ env:
|
|
||||||
- CEREMONY_SELF_REF: "0.6.1"
|
|
||||||
+ CEREMONY_SELF_REF: "0.6.3"
|
|
||||||
```
|
|
||||||
|
|
||||||
No release-door logic, decision table, fact gathering, version handling,
|
|
||||||
changelog handling, forge adapter or publish step changed across two
|
|
||||||
releases. The measured diff is one line.
|
|
||||||
|
|
||||||
2. The measured release path is exactly the output of
|
|
||||||
`.github/scripts/release-path.sh` at this head:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.github/workflows/release.yml
|
|
||||||
bin/
|
|
||||||
lib/version.sh
|
|
||||||
lib/decide.sh
|
|
||||||
lib/facts.sh
|
|
||||||
lib/changelog.sh
|
|
||||||
lib/forge.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
3. `drills/0.6.1.md` records a **full rehearsal** — run 2026-08-09 by
|
|
||||||
`codex-reviewer-andresmgsl` against release PR !226 — its release is
|
|
||||||
published, and `main` was re-armed to `0.6.2-dev` after it by
|
|
||||||
`5693bee chore: bump main to 0.6.2-dev`.
|
|
||||||
|
|
||||||
## What this release contains
|
|
||||||
|
|
||||||
Nine fragments consumed into `## 0.6.3`: #234, #240, #241, #243, #247, #251,
|
|
||||||
#253, #263, #265. None of them touches the release path; the list is
|
|
||||||
board-flow, labels-reconcile, docs and forge-adapter work.
|
|
||||||
|
|
||||||
## What is NOT asserted
|
|
||||||
|
|
||||||
This record asserts only that a fresh disposable-repo rehearsal would execute
|
|
||||||
the same release-door bytes as `0.6.1`'s did. It does not assert that the
|
|
||||||
doors were re-exercised, and it is not a substitute for the rehearsal owed at
|
|
||||||
the next release-path change. Per `drills/README.md`, the release PR's panel
|
|
||||||
verifies this claim like any other evidence, and **if any reviewer rules a full
|
|
||||||
drill owed, that verdict wins.**
|
|
||||||
|
|
||||||
## Note on 0.6.2's red `self-guards`
|
|
||||||
|
|
||||||
Recorded here because the next reader of `drills/` will see it. The `0.6.2`
|
|
||||||
tag's commit `5a8fce83` is red on `CI / self-guards` — `changelog-armed`
|
|
||||||
correctly refused a tree in which `changelog.d/238.md` was still unconsumed,
|
|
||||||
because that fragment landed on `main` after `changelog-assemble 0.6.2` had
|
|
||||||
already run in the release branch. The content shipped correct (the #238 entry
|
|
||||||
is in the published `0.6.2` notes) and the strand was consumed on `main` by
|
|
||||||
`f221647`. The systemic guard against the race landed in !255, *"refuse release
|
|
||||||
PRs that strand target fragments"*, which is why this candidate was checked
|
|
||||||
against it before opening.
|
|
||||||
|
|
@ -66,55 +66,7 @@ record is the only thing that survives the drill, and 0.2.0's record shipped
|
||||||
its first draft asserting a cleanup that had not happened (#135) — false
|
its first draft asserting a cleanup that had not happened (#135) — false
|
||||||
evidence in the one file whose job is to be evidence.
|
evidence in the one file whose job is to be evidence.
|
||||||
|
|
||||||
A record has one of three shapes. A **rehearsal** records the disposable-repo
|
|
||||||
run above. **Doors unchanged** records the mechanically checked claim below
|
|
||||||
when a new rehearsal would execute the same bytes as the last one. **WAIVED**
|
|
||||||
records a maintainer's judgement under the standing paragraph below. If the
|
|
||||||
doors-unchanged conditions do not all hold, the release owes a rehearsal or a
|
|
||||||
waiver; the narrower shape is never a substitute for either.
|
|
||||||
|
|
||||||
## Doors unchanged
|
|
||||||
|
|
||||||
The builder may assert that no disposable-repo rehearsal is owed only when
|
|
||||||
all three conditions below hold at the candidate head. The release PR's panel
|
|
||||||
verifies the claim like any other evidence, and if any reviewer rules a full
|
|
||||||
drill owed, that verdict wins.
|
|
||||||
|
|
||||||
1. `git diff <last-rehearsed-tag>..HEAD -- <release-path>` contains no change
|
|
||||||
except the `CEREMONY_SELF_REF` pin line in
|
|
||||||
`.github/workflows/release.yml`.
|
|
||||||
2. The release path is exactly the output of
|
|
||||||
`.github/scripts/release-path.sh`. Run the script and paste its output into
|
|
||||||
the record; measure the diff with
|
|
||||||
`git diff <last-rehearsed-tag>..HEAD -- $(sh .github/scripts/release-path.sh)`.
|
|
||||||
The script's contract test keeps its list and the workflow's direct and
|
|
||||||
transitive dependencies in agreement.
|
|
||||||
3. The last rehearsed tag's own record is a full rehearsal, its release is
|
|
||||||
published, and `main` was re-armed to `-dev` after it.
|
|
||||||
|
|
||||||
The baseline is the last **rehearsed** tag, never merely the previous tag. A
|
|
||||||
previous-tag baseline could chain one doors-unchanged assertion from another
|
|
||||||
while the doors drift a small diff at a time; the last-rehearsed anchor makes
|
|
||||||
any accumulated release-path change force a new rehearsal.
|
|
||||||
|
|
||||||
The record carries all three measurements as observed at its candidate head,
|
|
||||||
never copied from an earlier record. `drills/0.4.1.md` and
|
|
||||||
`drills/0.5.0.md` are the worked examples; the latter's amendment from a
|
|
||||||
predicted empty `lib/` diff to the observed `lib/ruling.sh` delta is why each
|
|
||||||
candidate is measured afresh (#233). Re-running its stricter baseline now is
|
|
||||||
also the path-enumeration proof: `git diff 0.4.0 0.5.0 -- <release-path>` is
|
|
||||||
only the `CEREMONY_SELF_REF` pin, while adding `lib/ruling.sh` makes the diff
|
|
||||||
non-empty even though neither release door reads that file (#217, #237).
|
|
||||||
|
|
||||||
`actions/drill-recorded` refuses any bare-version tree whose record is
|
`actions/drill-recorded` refuses any bare-version tree whose record is
|
||||||
missing or blank. A waived drill is still a record: the file says WAIVED and
|
missing or blank. A waived drill is still a record: the file says WAIVED and
|
||||||
why — a maintainer's call, visible and reviewable in the release PR's diff,
|
why — a maintainer's call, visible and reviewable in the release PR's diff,
|
||||||
never a silent skip.
|
never a silent skip.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Standing runner probes are not drills.** The disposal rule above — builder
|
|
||||||
archives, operator deletes — is for the disposable scratch repo a drill runs
|
|
||||||
in. `heavy-duty/ceremony-runner-probe` is the opposite shape: it stands, and
|
|
||||||
archiving it is the failure mode that made all three previous drill repos
|
|
||||||
unavailable. See [docs/RUNNER-PROBES.md](../docs/RUNNER-PROBES.md) (#202).
|
|
||||||
|
|
|
||||||
104
lib/attention.sh
104
lib/attention.sh
|
|
@ -1,104 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/attention.sh — the `attention` target invariants (#232, epic #229).
|
|
||||||
#
|
|
||||||
# Both reconcilers source this file. Pure decisions sit above the divider;
|
|
||||||
# the impure orchestrator below reads the current label episode and comments
|
|
||||||
# through the sourcing script's run()/log(). The machine diagnoses only: it
|
|
||||||
# never sets, clears, retargets or assigns anything on the strength of these
|
|
||||||
# checks (#229 D2).
|
|
||||||
|
|
||||||
ATTENTION_MARKER_PREFIX='<!-- ceremony:attention-malformed:'
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pure decisions. Facts in, verdict out. No gh, no clock.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
attention_target_decision() { # $1 pr|issue, $2 assignee count → MALFORMED_* | KEEP
|
|
||||||
case "$1" in
|
|
||||||
pr) echo MALFORMED_PR ;;
|
|
||||||
issue)
|
|
||||||
if [ "$2" -eq 0 ]; then echo MALFORMED_UNASSIGNED; else echo KEEP; fi ;;
|
|
||||||
*) return 2 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
attention_comment_decision() { # $1 target verdict, $2 suppression → POST | SUPPRESS | KEEP
|
|
||||||
case "$1" in
|
|
||||||
KEEP) echo KEEP ;;
|
|
||||||
MALFORMED_UNASSIGNED)
|
|
||||||
if [ -n "$2" ]; then echo SUPPRESS; else echo POST; fi ;;
|
|
||||||
MALFORMED_PR) echo POST ;;
|
|
||||||
*) return 2 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
attention_newest_flag() { # labeled-event ISO-8601 timestamps on stdin → newest
|
|
||||||
sort | tail -n1
|
|
||||||
}
|
|
||||||
|
|
||||||
attention_episode_marker() { # $1 current episode's labeled timestamp
|
|
||||||
printf '%s%s -->\n' "$ATTENTION_MARKER_PREFIX" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The impure orchestrator. Called only behind a has-attention gate.
|
|
||||||
# Needs REPO; uses the caller's run() and log().
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
|
|
||||||
local n="$1" surface="$2" assignees="$3" suppression="${4:-}"
|
|
||||||
local target comment labeled_events labeled_at marker comments body timeline
|
|
||||||
: "${REPO:?reconcile_attention: REPO is required}"
|
|
||||||
|
|
||||||
target="$(attention_target_decision "$surface" "$assignees")"
|
|
||||||
comment="$(attention_comment_decision "$target" "$suppression")"
|
|
||||||
[ "$comment" != KEEP ] || return 0
|
|
||||||
|
|
||||||
if [ "$comment" = SUPPRESS ]; then
|
|
||||||
log "#$n: malformed attention detected; comment suppressed by $suppression precedence"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# forge_timeline projects both forges into the GitHub event shape
|
|
||||||
# (.event / .label.name) — Forgejo's raw timeline carries neither. Capture
|
|
||||||
# its status BEFORE jq: a pipeline's status is the last command's, so
|
|
||||||
# `forge_timeline | jq` would collapse an unreadable timeline into an empty
|
|
||||||
# one, and those are the two states this function exists to tell apart
|
|
||||||
# (#188, and lib/ruling.sh does the identical dance).
|
|
||||||
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
|
|
||||||
log "#$n: attention timeline unreadable — no verdict invented this pass"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
labeled_events="$(jq -r '.[]
|
|
||||||
| select(.event == "labeled" and .label.name == "attention")
|
|
||||||
| .created_at' <<<"$timeline")"
|
|
||||||
if [ -z "$labeled_events" ]; then
|
|
||||||
log "#$n: attention flag has no visible labeled event — no verdict invented this pass"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
labeled_at="$(attention_newest_flag <<<"$labeled_events")"
|
|
||||||
marker="$(attention_episode_marker "$labeled_at")"
|
|
||||||
|
|
||||||
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
|
|
||||||
--jq '.[].body // ""' 2>/dev/null)"; then
|
|
||||||
log "#$n: attention comments unreadable — no verdict invented this pass"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
grep -qF "$marker" <<<"$comments" && return 0
|
|
||||||
|
|
||||||
case "$target" in
|
|
||||||
MALFORMED_PR)
|
|
||||||
body="$marker
|
|
||||||
This pull request carries \`attention\`, but that label is issue-only. Put
|
|
||||||
the label on the assigned issue that owns the claim. The sweep cannot infer
|
|
||||||
which issue that is, so it reports the malformed target without removing or
|
|
||||||
retargeting the label." ;;
|
|
||||||
MALFORMED_UNASSIGNED)
|
|
||||||
body="$marker
|
|
||||||
This issue carries \`attention\` but has no assignee to receive the demand.
|
|
||||||
Assign the intended builder or remove the flag. The sweep reports the board
|
|
||||||
bug without assigning anyone or changing the label." ;;
|
|
||||||
esac
|
|
||||||
run forge_issue_comment "$n" "$body" >/dev/null
|
|
||||||
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
|
|
||||||
}
|
|
||||||
159
lib/changelog.sh
159
lib/changelog.sh
|
|
@ -106,31 +106,8 @@ changelog_fragments() {
|
||||||
# publisher enforces at release time, moved onto the PR;
|
# publisher enforces at release time, moved onto the PR;
|
||||||
# - no '### ' heading without a bullet before the next heading or EOF:
|
# - no '### ' heading without a bullet before the next heading or EOF:
|
||||||
# the dangling grouped heading #98 taught us to refuse.
|
# the dangling grouped heading #98 taught us to refuse.
|
||||||
# - no entry longer than 300 characters (#167): 0.3.0 shipped a cluster of
|
|
||||||
# 316–789-character entries straight through the prose rule, so the
|
|
||||||
# bound moves onto the PR like every other fragment rule. Measured on
|
|
||||||
# the normalized entry — continuation lines joined, whitespace runs
|
|
||||||
# collapsed to one space, the '- '/'* ' marker stripped, the '(#N)'
|
|
||||||
# citation included — so wrapping alone can never red an entry. 300
|
|
||||||
# splits the measured history: every healthy entry passes untouched,
|
|
||||||
# the drift cluster does not. mawk's length() counts bytes; prose here
|
|
||||||
# is ASCII and the fuzz is acceptable.
|
|
||||||
# - every entry ends with its issue citation (#262): one '(' group of
|
|
||||||
# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then
|
|
||||||
# ')', then the final '.' and nothing after it. Stated as style and
|
|
||||||
# enforced by nobody, this rule cost #255 a full four-bot round on a
|
|
||||||
# missing '(#248)'; the fragment rules that live in this guard drew no
|
|
||||||
# review comment at all across the same fifteen PRs. Measured on the
|
|
||||||
# same normalized entry as the bound above, so a citation that wraps
|
|
||||||
# onto a continuation line still counts. The repo token is the one the
|
|
||||||
# filename rule already admits, so '<repo>-<issue>.md' and its cite
|
|
||||||
# cannot drift apart; the two halves of one convention. A single group
|
|
||||||
# is what makes 'terminal' checkable — '(#236, #250).' lands two issues
|
|
||||||
# in one entry, '(#236) and (#250).' does not. The citation need not
|
|
||||||
# name the file's own issue: the filename already carries the
|
|
||||||
# authorizing one, so a fragment may cite the incident beside it.
|
|
||||||
changelog_fragment_problem() {
|
changelog_fragment_problem() {
|
||||||
local file="$1" base problem kind detail rest
|
local file="$1" base problem
|
||||||
base="${file##*/}"
|
base="${file##*/}"
|
||||||
|
|
||||||
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
|
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
|
||||||
|
|
@ -170,97 +147,6 @@ changelog_fragment_problem() {
|
||||||
printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem"
|
printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# One walk of the entries, two rules, and the order between them is
|
|
||||||
# deliberate: an over-long entry anywhere outranks a citation problem
|
|
||||||
# anywhere, so the length diagnosis a fragment already draws is the same
|
|
||||||
# one it drew before the citation rule existed. Both read the entry the
|
|
||||||
# same normalizer produces, which is the whole reason they share a pass.
|
|
||||||
problem="$(
|
|
||||||
awk -v max=300 '
|
|
||||||
# cite_problem <entry> — "", "uncited" or "misplaced". Counting the
|
|
||||||
# groups is what distinguishes the two admitted shapes: one group
|
|
||||||
# closing the entry passes however many references it carries, and a
|
|
||||||
# second group anywhere means no single group is terminal.
|
|
||||||
function cite_problem(e, rest, groups, consumed, group_end) {
|
|
||||||
rest = e
|
|
||||||
groups = 0
|
|
||||||
consumed = 0
|
|
||||||
while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) {
|
|
||||||
groups++
|
|
||||||
group_end = consumed + RSTART + RLENGTH - 1
|
|
||||||
consumed = group_end
|
|
||||||
rest = substr(rest, RSTART + RLENGTH)
|
|
||||||
}
|
|
||||||
if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited"
|
|
||||||
if (groups > 1) return "misplaced"
|
|
||||||
return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced"
|
|
||||||
}
|
|
||||||
function excerpt(e) {
|
|
||||||
return length(e) > 60 ? substr(e, 1, 60) "…" : e
|
|
||||||
}
|
|
||||||
function flush( len, e, kind) {
|
|
||||||
if (entry == "") return 0
|
|
||||||
e = entry
|
|
||||||
entry = ""
|
|
||||||
gsub(/[[:space:]]+/, " ", e)
|
|
||||||
sub(/^ /, "", e)
|
|
||||||
sub(/ $/, "", e)
|
|
||||||
len = length(e)
|
|
||||||
if (len > max) {
|
|
||||||
reported = 1
|
|
||||||
printf "long\t%d\t%s\n", len, excerpt(e)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
kind = cite_problem(e)
|
|
||||||
if (kind != "" && cite_kind == "") {
|
|
||||||
cite_kind = kind
|
|
||||||
cite_excerpt = excerpt(e)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
/^### / { if (flush()) exit; next }
|
|
||||||
/^[[:space:]]*[-*][[:space:]]/ {
|
|
||||||
if (flush()) exit
|
|
||||||
entry = $0
|
|
||||||
sub(/^[[:space:]]*[-*][[:space:]]+/, "", entry)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^[[:space:]]*$/ { next }
|
|
||||||
entry != "" { entry = entry " " $0 }
|
|
||||||
# An exit from a main rule still runs END, so a length row printed
|
|
||||||
# mid-file would be followed by the citation row it outranks — two
|
|
||||||
# lines spliced into one diagnosis, the internal protocol row landing
|
|
||||||
# inside the human-facing excerpt (#262 round 1). The reported flag is
|
|
||||||
# the same guard the empty-heading walk above uses, for the same
|
|
||||||
# reason: one diagnosis per fragment is the contract.
|
|
||||||
END {
|
|
||||||
if (flush()) exit
|
|
||||||
if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt
|
|
||||||
}
|
|
||||||
' "$file"
|
|
||||||
)"
|
|
||||||
if [ -n "$problem" ]; then
|
|
||||||
kind="${problem%%$'\t'*}"
|
|
||||||
rest="${problem#*$'\t'}"
|
|
||||||
detail="${rest%%$'\t'*}"
|
|
||||||
rest="${rest#*$'\t'}"
|
|
||||||
case "$kind" in
|
|
||||||
long)
|
|
||||||
printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
|
|
||||||
"$file" "$detail" "$rest"
|
|
||||||
;;
|
|
||||||
uncited)
|
|
||||||
printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \
|
|
||||||
"$file" "$rest"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \
|
|
||||||
"$file" "$rest"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# changelog_shape_problem <changelog> <fragments-dir>
|
# changelog_shape_problem <changelog> <fragments-dir>
|
||||||
|
|
@ -270,38 +156,9 @@ changelog_fragment_problem() {
|
||||||
# definition shared by the PR-time guard and the release-time assembler:
|
# definition shared by the PR-time guard and the release-time assembler:
|
||||||
# fragments may not mix grouped headings with ungrouped bullets, and a
|
# fragments may not mix grouped headings with ungrouped bullets, and a
|
||||||
# non-empty set must match the newest published section when one exists.
|
# non-empty set must match the newest published section when one exists.
|
||||||
#
|
|
||||||
# The anchor is declarable (#182): an optional sentinel '<dir>/shape',
|
|
||||||
# holding exactly 'flat' or 'grouped' on one line, pins the set's shape and
|
|
||||||
# outranks the newest-published-section inference — the door a deliberate
|
|
||||||
# flip walks through, while undeclared drift stays red (#159). Absent, the
|
|
||||||
# inference binds unchanged. Any other content — empty, trailing junk, an
|
|
||||||
# unknown word — is a diagnosis naming the file, never a silent fallback.
|
|
||||||
# The sentinel lives in the fragments dir so it binds in both callers: the
|
|
||||||
# assembler calls with changelog="" and still sees it. It is not a fragment
|
|
||||||
# — changelog_fragments matches *.md only, so 'shape' never enters the list.
|
|
||||||
changelog_shape_problem() {
|
changelog_shape_problem() {
|
||||||
local changelog="$1" dir="$2"
|
local changelog="$1" dir="$2"
|
||||||
local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
|
local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
|
||||||
local sentinel="$dir/shape" declared=""
|
|
||||||
|
|
||||||
if [ -f "$sentinel" ]; then
|
|
||||||
# The one-line contract is checked on the file itself: command
|
|
||||||
# substitution strips every trailing newline, so the captured word
|
|
||||||
# cannot tell 'grouped' from 'grouped' plus blank lines.
|
|
||||||
if [ "$(wc -l <"$sentinel")" -gt 1 ]; then
|
|
||||||
printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
declared="$(cat "$sentinel")"
|
|
||||||
case "$declared" in
|
|
||||||
flat | grouped) ;;
|
|
||||||
*)
|
|
||||||
printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
fragments="$(changelog_fragments "$dir")"
|
fragments="$(changelog_fragments "$dir")"
|
||||||
[ -n "$fragments" ] || return 0
|
[ -n "$fragments" ] || return 0
|
||||||
|
|
@ -327,20 +184,6 @@ changelog_shape_problem() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$declared" ]; then
|
|
||||||
if [ "$declared" = "grouped" ] && [ -n "$ungrouped_in" ]; then
|
|
||||||
printf "fragment '%s' is flat but '%s' declares grouped — a repo is one shape or the other\n" \
|
|
||||||
"$ungrouped_in" "$sentinel"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [ "$declared" = "flat" ] && [ -n "$grouped_in" ]; then
|
|
||||||
printf "fragment '%s' is grouped but '%s' declares flat — a repo is one shape or the other\n" \
|
|
||||||
"$grouped_in" "$sentinel"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$changelog" ]; then
|
if [ -f "$changelog" ]; then
|
||||||
published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")"
|
published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/closes_references.sh — "which issues does this PR body close?", parsed
|
|
||||||
# here rather than asked of a forge (issue #188, term 3).
|
|
||||||
#
|
|
||||||
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
|
|
||||||
# options, as lib/version.sh and lib/forge.sh do.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
|
|
||||||
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
|
|
||||||
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
|
|
||||||
# it from the other side: a real forgejo-runner job arrives with
|
|
||||||
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
|
|
||||||
# So that call site could not be translated to a Forgejo endpoint — there is
|
|
||||||
# nothing to translate it to. It had to be replaced by a parse this repo
|
|
||||||
# owns, over a field both forges already return:
|
|
||||||
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
|
|
||||||
# /api/v3 and /api/v1 alike (measured on both).
|
|
||||||
#
|
|
||||||
# That the replacement is honest is the point. The sibling half of the same
|
|
||||||
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
|
|
||||||
# `number` and `body` and ran them through refs_references. GraphQL was
|
|
||||||
# buying pagination convenience there, nothing semantic. This file makes the
|
|
||||||
# other half symmetric: one parser this repo controls and can test, for both
|
|
||||||
# link kinds, on both forges.
|
|
||||||
#
|
|
||||||
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
|
|
||||||
# records closing links attached through the pull request's development
|
|
||||||
# sidebar, which live in no body and which no body parse can see. This
|
|
||||||
# family declares its links in the body — that is what BUILDER.md's PR
|
|
||||||
# template asks for — so the delta is zero in practice here. A consumer that
|
|
||||||
# links through the sidebar would see those issues go unclosed by the sweep;
|
|
||||||
# they would need to say so in the body instead.
|
|
||||||
#
|
|
||||||
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
|
|
||||||
# CROSS classifier that keeps rig#112 from ever being read as local #112
|
|
||||||
# (#61). Bash resolves function calls at call time, so the order of sourcing
|
|
||||||
# does not matter; both must simply be defined before closes_references runs.
|
|
||||||
# refs_references depends on it exactly the same way.
|
|
||||||
|
|
||||||
# closes_references — PR body on stdin -> local issue numbers this body
|
|
||||||
# declares it CLOSES, sorted, unique.
|
|
||||||
#
|
|
||||||
# The keyword set is GitHub's documented one, all three verbs in all three
|
|
||||||
# tenses. Matching is case-insensitive because bodies are written by humans
|
|
||||||
# and agents both ("Closes", "closes", "CLOSES").
|
|
||||||
#
|
|
||||||
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
|
|
||||||
# refs_references owns it, and conflating them would make every referenced
|
|
||||||
# issue look closeable, which is the post-merge transition #151 had to be
|
|
||||||
# reopened by hand over.
|
|
||||||
closes_references() {
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
line = $0
|
|
||||||
lower = tolower(line)
|
|
||||||
# Every occurrence contributes, not just the first: a body that says
|
|
||||||
# "Closes #1. Closes #2." declares two, and binding to the first
|
|
||||||
# occurrence dropped the later ones — the same defect #184 fixed in
|
|
||||||
# blocked_reference_records, kept fixed here by construction.
|
|
||||||
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
|
|
||||||
# BOTH cursors advance together. Advancing only `lower` left the
|
|
||||||
# next match offset indexing the ORIGINAL line, so the second
|
|
||||||
# declaration on a line came back as garbage — caught by the
|
|
||||||
# "two closes on one line" case, which is why it is a case.
|
|
||||||
rest = substr(line, RSTART + RLENGTH)
|
|
||||||
line = rest
|
|
||||||
lower = tolower(rest)
|
|
||||||
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
|
||||||
token = rest
|
|
||||||
# Stop at the first thing that cannot be part of a reference, so
|
|
||||||
# "Closes #12, and more prose" yields #12 and not the sentence.
|
|
||||||
sub(/[^[:alnum:]_.\/#-].*/, "", token)
|
|
||||||
print token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' | issue_references \
|
|
||||||
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
|
||||||
}
|
|
||||||
62
lib/facts.sh
62
lib/facts.sh
|
|
@ -4,7 +4,7 @@
|
||||||
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
|
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
|
||||||
# 5-state verdict. This script is the impure half that establishes those
|
# 5-state verdict. This script is the impure half that establishes those
|
||||||
# facts. It runs inside the consumer's checkout (the working directory),
|
# facts. It runs inside the consumer's checkout (the working directory),
|
||||||
# talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
|
# talks to git and gh, and prints the facts in $GITHUB_OUTPUT form:
|
||||||
#
|
#
|
||||||
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
|
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
|
||||||
#
|
#
|
||||||
|
|
@ -16,8 +16,7 @@
|
||||||
# MERGE_SHA the pushed head (github.sha)
|
# MERGE_SHA the pushed head (github.sha)
|
||||||
# EVENT_BEFORE github.event.before — may be empty or all-zeros
|
# EVENT_BEFORE github.event.before — may be empty or all-zeros
|
||||||
# GITHUB_REPOSITORY for the two API facts
|
# GITHUB_REPOSITORY for the two API facts
|
||||||
# GH_TOKEN for the forge client (unused when no API state is
|
# GH_TOKEN for gh (unused when no API state is consulted)
|
||||||
# consulted)
|
|
||||||
#
|
#
|
||||||
# The API calls run only in the states that consult them (decide tolerates
|
# The API calls run only in the states that consult them (decide tolerates
|
||||||
# empty facts — issue #8): RELEASED only for a bare unchanged version,
|
# empty facts — issue #8): RELEASED only for a bare unchanged version,
|
||||||
|
|
@ -25,11 +24,8 @@
|
||||||
# decides on the two versions alone and never touches the API.
|
# decides on the two versions alone and never touches the API.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=lib/version.sh
|
# shellcheck source=lib/version.sh
|
||||||
. "$_facts_lib/version.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$_facts_lib/forge.sh"
|
|
||||||
|
|
||||||
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
|
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
|
||||||
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
|
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
|
||||||
|
|
@ -98,45 +94,25 @@ fi
|
||||||
released=""
|
released=""
|
||||||
labeled=""
|
labeled=""
|
||||||
if ! version_is_dev "$ver"; then
|
if ! version_is_dev "$ver"; then
|
||||||
# The forge is selected only in the states that consult the API — a -dev
|
|
||||||
# tree, every ordinary merge, still decides on the two versions alone and
|
|
||||||
# touches no forge at all (#8's tolerance for empty facts).
|
|
||||||
# The forgejo backend addresses the repo through REPO; the github backend
|
|
||||||
# reads GITHUB_REPOSITORY directly. Set it here from the one this script
|
|
||||||
# already documents, so the two backends address the same repository —
|
|
||||||
# missing it made every forgejo read refuse with "REPO: unbound variable"
|
|
||||||
# (caught by release-exercise on !193).
|
|
||||||
REPO="${REPO:-${GITHUB_REPOSITORY:?facts: GITHUB_REPOSITORY is required for the API facts}}"
|
|
||||||
export REPO
|
|
||||||
# "" means decide from the environment; forge_select takes an explicit
|
|
||||||
# forge only in tests.
|
|
||||||
forge_select "" || exit 1
|
|
||||||
|
|
||||||
if [ "$base_ver" = "$ver" ]; then
|
if [ "$base_ver" = "$ver" ]; then
|
||||||
# Row 4's input. Before #191 any failure here read as "not released",
|
# Any gh failure reads as "not released" — the sources' semantics; the
|
||||||
# which is safe only because row 4 refuses either way. It is still a
|
# verdict this feeds (row 4) is a refusal, and the ceremony path
|
||||||
# lie about what was observed, so an unreadable answer refuses.
|
# re-checks existence in the nothing-exists assert before creating
|
||||||
if ! released="$(forge_release_exists "$ver")"; then
|
# anything.
|
||||||
echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2
|
if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
|
||||||
exit 1
|
released=yes
|
||||||
|
else
|
||||||
|
released=no
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Row 5's input, and the one that cost a release: a push event carries
|
# The sources' exact jq: merged PRs only, `release` among the label
|
||||||
# no PR payload, so the label is read from the API. The old code turned
|
# names. Read via the API because a push event carries no PR payload —
|
||||||
# ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that
|
# and the PR itself lives on a fork (the trigger comment in the
|
||||||
# demoted a correctly labeled, correctly merged ceremony PR into "a bare
|
# workflow). A failed API call reads as "no label", which row 5
|
||||||
# push", refusing the release and creating nothing. Measured in the
|
# refuses: fail-closed.
|
||||||
# 0.4.1 drill, twice (drills/0.4.1.md).
|
if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
|
||||||
#
|
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \
|
||||||
# Now: a completed read that finds no merged release-labeled PR is still
|
| grep -qx true; then
|
||||||
# `no` and still fail-closed. A read that did not complete refuses.
|
|
||||||
if ! pulls="$(forge_commit_pulls "$MERGE_SHA")"; then
|
|
||||||
echo "facts: could not read the pull requests behind '$MERGE_SHA' — refusing rather than reporting 'no label' (#191)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# One jq expression for both forges: the backends agree on the shape.
|
|
||||||
if printf '%s' "$pulls" \
|
|
||||||
| jq -e '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' >/dev/null 2>&1; then
|
|
||||||
labeled=yes
|
labeled=yes
|
||||||
else
|
else
|
||||||
labeled=no
|
labeled=no
|
||||||
|
|
|
||||||
|
|
@ -1,891 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
|
|
||||||
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
|
|
||||||
# forgejo; never sourced directly, and never at the same time as the github
|
|
||||||
# backend — they define the same verbs on purpose.
|
|
||||||
#
|
|
||||||
# curl+jq rather than a CLI because that is what the runner has. The image
|
|
||||||
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
|
|
||||||
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
|
|
||||||
|
|
||||||
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
|
|
||||||
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
|
|
||||||
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
|
|
||||||
# it for tests and for anyone driving this outside Actions.
|
|
||||||
forgejo_api_base() {
|
|
||||||
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
|
|
||||||
if [ -z "$base" ]; then
|
|
||||||
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Every verb in this backend interpolates $REPO into its path, and every
|
|
||||||
# one of them reaches the network through here — so this is the one place
|
|
||||||
# that can make `repos//…` impossible.
|
|
||||||
#
|
|
||||||
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
|
||||||
# @kimi on !193 before it shipped):
|
|
||||||
#
|
|
||||||
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
|
||||||
# 404s; a repo-less path read
|
|
||||||
# as "the published release
|
|
||||||
# does not exist" — and the
|
|
||||||
# nothing-exists assert would
|
|
||||||
# then proceed to CREATE)
|
|
||||||
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
|
||||||
# PR behind it, read as none)
|
|
||||||
#
|
|
||||||
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
|
|
||||||
# empty and 404s into a fabricated fact instead of crashing. That is the
|
|
||||||
# exact failure #191 exists to remove, so it refuses here rather than
|
|
||||||
# anywhere later.
|
|
||||||
if [ -z "${REPO:-}" ]; then
|
|
||||||
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "${base%/}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
|
|
||||||
# testable without a network. Returns the endpoint with this backend's OWN
|
|
||||||
# paging parameters applied.
|
|
||||||
#
|
|
||||||
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
|
|
||||||
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
|
|
||||||
#
|
|
||||||
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
|
||||||
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
|
||||||
#
|
|
||||||
# Each forge silently ignores the other's page-size parameter, answers
|
|
||||||
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
|
|
||||||
# was written GitHub-shaped, so a verbatim port would have swept 30 of
|
|
||||||
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
|
|
||||||
# green, and the same "degraded read that does not report it degraded"
|
|
||||||
# failure class this whole issue exists to kill.
|
|
||||||
#
|
|
||||||
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
|
|
||||||
# boundary once beats fixing nine call sites and trusting the tenth — the
|
|
||||||
# same argument that chose shape C over B, one level down.
|
|
||||||
#
|
|
||||||
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
|
|
||||||
# this instance) whatever you ask for, so asking for more cannot help and
|
|
||||||
# pagination is mandatory rather than an optimisation.
|
|
||||||
forgejo_page_url() {
|
|
||||||
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
|
|
||||||
# Strip any page-size parameter a caller left behind, in either dialect,
|
|
||||||
# rather than trusting that none did: this function is the one place that
|
|
||||||
# decides paging, and a stray per_page= would be exactly the silent
|
|
||||||
# truncation above.
|
|
||||||
local clean="$endpoint"
|
|
||||||
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
||||||
case "$clean" in
|
|
||||||
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
|
|
||||||
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
|
|
||||||
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
|
|
||||||
#
|
|
||||||
# --paginate walks page= until a short page, then PROVES the walk was
|
|
||||||
# complete by comparing what it collected against the server's declared
|
|
||||||
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
|
|
||||||
# header is a loud refusal, not a pass. Header exposure is a server setting
|
|
||||||
# (access-control-expose-headers), and an instance that withholds it would
|
|
||||||
# make the completeness check compare null to a number — the guard itself
|
|
||||||
# degrading silently, which is the failure class re-entering through the
|
|
||||||
# door built to stop it.
|
|
||||||
#
|
|
||||||
# --paginate-exhaustive is the narrow alternative for an endpoint whose
|
|
||||||
# x-total-count is known not to describe the collection. It proves completion
|
|
||||||
# by reading through the first short page and never consults that header.
|
|
||||||
forge_api() {
|
|
||||||
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--paginate) paginate=true ;;
|
|
||||||
--paginate-exhaustive) paginate_exhaustive=true ;;
|
|
||||||
-X | --method)
|
|
||||||
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
|
|
||||||
method="$2"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-X?*) method="${1#-X}" ;;
|
|
||||||
--method=*)
|
|
||||||
method="${1#*=}"
|
|
||||||
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; return 1; }
|
|
||||||
;;
|
|
||||||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
|
||||||
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
|
|
||||||
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
|
|
||||||
echo "forge_api: pagination is available only for GET requests" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local base token
|
|
||||||
base="$(forgejo_api_base)" || return 1
|
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
||||||
|
|
||||||
local hdr body
|
|
||||||
hdr="$(mktemp)"; body="$(mktemp)"
|
|
||||||
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
|
||||||
trap "rm -f '$hdr' '$body'" RETURN
|
|
||||||
|
|
||||||
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
|
|
||||||
if ! curl -sS -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
||||||
"$base/$endpoint"; then
|
|
||||||
echo "forge_api: request failed: $endpoint" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
|
||||||
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Paginated: accumulate into ONE array and apply --jq once at the end.
|
|
||||||
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
|
|
||||||
# shapes every call site here uses, the two are identical, and merging
|
|
||||||
# first is what makes the completeness assert possible at all.
|
|
||||||
local page=1 total="" got=0 n all="[]" pagejson
|
|
||||||
while :; do
|
|
||||||
if ! curl -sS -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
||||||
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
|
|
||||||
echo "forge_api: request failed: $endpoint (page $page)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
|
||||||
|
|
||||||
if [ "$paginate_exhaustive" = false ]; then
|
|
||||||
# Re-read on EVERY page, not once (#4712). A board that changes size
|
|
||||||
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
|
||||||
# 9 stopped at 4 believing itself whole. A moving total means the read
|
|
||||||
# cannot have been atomic, so it is refused rather than reconciled.
|
|
||||||
local page_total
|
|
||||||
page_total="$(forgejo_total_count "$hdr")" || return 1
|
|
||||||
if [ -z "$total" ]; then
|
|
||||||
total="$page_total"
|
|
||||||
elif [ "$page_total" != "$total" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
|
|
||||||
The collection moved under the walk, so no page set can be proven whole.
|
|
||||||
Refusing rather than reconciling a board that is already out of date.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
pagejson="$(cat "$body")"
|
|
||||||
# A 200 whose body is not a collection counted as zero items (#4712),
|
|
||||||
# so an error object or a scalar arriving where a list belongs read as
|
|
||||||
# a complete EMPTY collection whenever the declared total was 0.
|
|
||||||
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: '$endpoint' did not return a collection (#188).
|
|
||||||
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
|
|
||||||
Refusing: a body this shim cannot count must not be counted as empty.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
n="$(jq 'length' <<<"$pagejson")"
|
|
||||||
[ "$n" -gt 0 ] || break
|
|
||||||
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
|
||||||
got=$((got + n))
|
|
||||||
if [ "$paginate_exhaustive" = true ]; then
|
|
||||||
[ "$n" -eq 50 ] || break
|
|
||||||
else
|
|
||||||
[ "$got" -lt "$total" ] || break
|
|
||||||
fi
|
|
||||||
page=$((page + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
# The assert. A short read here is the silent-truncation bug arriving by
|
|
||||||
# another route, so it is fatal rather than a warning.
|
|
||||||
if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
|
||||||
Refusing rather than reconciling a partial board: a sweep over part of the
|
|
||||||
queue that reports success is the failure this shim exists to prevent.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_total_count <header-file> — the declared size of the collection.
|
|
||||||
# Absent is fatal (#4699): without it the completeness assert cannot run,
|
|
||||||
# and an assert that cannot run must not silently pass.
|
|
||||||
forgejo_total_count() {
|
|
||||||
local hdr="$1" total
|
|
||||||
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
|
|
||||||
if [ -z "$total" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
|
|
||||||
The header is exposed by a server setting (access-control-expose-headers).
|
|
||||||
Refusing: an unprovable read must not be reported as a whole one.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
|
|
||||||
# not-a-number` used to sail through and become the bound the walk was
|
|
||||||
# compared against — a guard whose own input was never checked.
|
|
||||||
case "$total" in
|
|
||||||
'' | *[!0-9]*)
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
|
|
||||||
Refusing: the completeness bound must be a number, or the assert that
|
|
||||||
uses it proves nothing.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
printf '%s\n' "$total"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_http_ok <header-file> <verb-and-endpoint> — a non-2xx is named, not
|
|
||||||
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
|
|
||||||
# and -f would throw away the body that says why.
|
|
||||||
# The second argument carries the VERB as well as the path — "GET repos/…",
|
|
||||||
# "PUT repos/…". #192's acceptance criterion is that a failure names the verb,
|
|
||||||
# the path and the status, and reads used to omit the verb: a caller reading
|
|
||||||
# `HTTP 500 from 'repos/o/r/issues/5'` could not tell a failed read from a
|
|
||||||
# failed write of the same path (@codex-reviewer-andresmgsl).
|
|
||||||
forgejo_http_ok() {
|
|
||||||
local hdr="$1" endpoint="$2" code
|
|
||||||
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
|
|
||||||
case "$code" in
|
|
||||||
2*) return 0 ;;
|
|
||||||
*)
|
|
||||||
echo "forge_api: HTTP $code from '$endpoint'" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the verbs the reconcilers use, over /api/v1 --------------------------
|
|
||||||
# Three asymmetries with gh, all measured against this instance on
|
|
||||||
# 2026-08-02 using a scratch repo (never a live board):
|
|
||||||
#
|
|
||||||
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
|
|
||||||
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
|
|
||||||
# DELETE /issues/{n}/labels/149 -> 204
|
|
||||||
# So a removal must resolve name -> id first. gh hides this; the shim
|
|
||||||
# cannot.
|
|
||||||
#
|
|
||||||
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
|
|
||||||
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
|
|
||||||
# a read-modify-write rather than a delete.
|
|
||||||
#
|
|
||||||
# 3. There is no statusCheckRollup. The portable equivalent is the
|
|
||||||
# combined commit status, GET /commits/{sha}/status, which returns
|
|
||||||
# {state, statuses[]}.
|
|
||||||
|
|
||||||
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
|
|
||||||
# call site that needs it. Paginated through forge_api, so a repo with more
|
|
||||||
# than one page of labels cannot silently lose the tail (#188).
|
|
||||||
forgejo_label_ids() {
|
|
||||||
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
|
|
||||||
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
|
|
||||||
forge_issue_edit() {
|
|
||||||
local n="${1:?forge_issue_edit: number required}"
|
|
||||||
shift
|
|
||||||
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
|
|
||||||
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
|
|
||||||
# given to `gh`, which fails on a flag it does not know; dropping it here
|
|
||||||
# instead would turn a port typo into a green no-op — a mutation that
|
|
||||||
# silently did not happen, which is precisely this issue's failure class
|
|
||||||
# arriving inside the fix for it.
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--add-label | --remove-label | --add-assignee | --remove-assignee)
|
|
||||||
if [ "$#" -lt 2 ]; then
|
|
||||||
echo "forge_issue_edit: $1 requires a value (#188)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
IFS=, read -ra v <<<"$2"
|
|
||||||
case "$1" in
|
|
||||||
--add-label) add_labels+=("${v[@]}") ;;
|
|
||||||
--remove-label) rm_labels+=("${v[@]}") ;;
|
|
||||||
--add-assignee) add_assignees+=("${v[@]}") ;;
|
|
||||||
--remove-assignee) rm_assignees+=("${v[@]}") ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# THE LABEL DELTA (#192). Removal used to be a per-label
|
|
||||||
# `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500
|
|
||||||
# for EVERY removal under the token the sweep actually holds — measured
|
|
||||||
# under a real Actions token inside a workflow, probe run 701:
|
|
||||||
#
|
|
||||||
# POST /issues/{n}/labels ["probe-a","probe-b"] -> 200
|
|
||||||
# DELETE /issues/{n}/labels/{id} -> 500 labels unchanged
|
|
||||||
# PUT /issues/{n}/labels {"labels":[<id>]} -> 200
|
|
||||||
# PUT /issues/{n}/labels {"labels":[]} -> 200 (full clear)
|
|
||||||
#
|
|
||||||
# A PAT gets 204 on the same DELETE, which is why this survived a week
|
|
||||||
# unseen: it fails only for `${{ github.token }}`, and only inside Actions.
|
|
||||||
# Net effect before this fix: on Forgejo the state machine could only ever
|
|
||||||
# ADD labels — every `state:*` transition needing the previous state cleared,
|
|
||||||
# and every `blocker:*` that should lift, was inert.
|
|
||||||
#
|
|
||||||
# So a removal is expressed as a full-set PUT, exactly as the assignee branch
|
|
||||||
# below expresses its own delta as one PATCH — read current, compute wanted,
|
|
||||||
# write once.
|
|
||||||
#
|
|
||||||
# AN ADD-ONLY CALL KEEPS ITS ADDITIVE POST, deliberately. ceremony#128 lost
|
|
||||||
# its `release` label — the merge door's declared-intent read — to a
|
|
||||||
# read-modify-write that clobbered a label set two seconds after a builder
|
|
||||||
# wrote it, and `forge_labels_add` is pinned against ever doing that
|
|
||||||
# (test/forge-backends.test.sh). The read-modify-write window is real and is
|
|
||||||
# accepted HERE and only here, where the caller has asked to REMOVE something
|
|
||||||
# and no additive verb can express that.
|
|
||||||
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
|
||||||
local current want_pairs ids id name payload
|
|
||||||
local want_ids=() missing=()
|
|
||||||
# name<TAB>id straight from the ISSUE payload. Preserved labels carry
|
|
||||||
# their authoritative id here already, so they need no second lookup —
|
|
||||||
# re-resolving them through the repository-wide list would make
|
|
||||||
# preservation depend on a paginated read that has nothing to do with
|
|
||||||
# this issue, and an incomplete one would drop a bystander
|
|
||||||
# (@codex-reviewer-andresmgsl). Only ADDED names need forgejo_label_ids.
|
|
||||||
current="$(forge_api "repos/$REPO/issues/$n" \
|
|
||||||
--jq '[.labels[]? | "\(.name)\t\(.id)"] | join("\n")')" || return 1
|
|
||||||
# The rows are name<TAB>id, so removals filter on the NAME field — a
|
|
||||||
# whole-line match would never fire against a pair.
|
|
||||||
want_pairs="$(
|
|
||||||
awk -F '\t' 'NR==FNR { drop[$0]=1; next } !($1 in drop)' \
|
|
||||||
<(printf '%s\n' "${rm_labels[@]}") \
|
|
||||||
<(printf '%s\n' "$current" | grep -v '^$')
|
|
||||||
)"
|
|
||||||
while IFS=$'\t' read -r name id; do
|
|
||||||
[ -n "$name" ] || continue
|
|
||||||
want_ids+=("$id")
|
|
||||||
done <<<"$want_pairs"
|
|
||||||
if [ "${#add_labels[@]}" -gt 0 ]; then
|
|
||||||
ids="$(forgejo_label_ids)" || return 1
|
|
||||||
for name in "${add_labels[@]}"; do
|
|
||||||
# already on the issue? its id is in want_ids already
|
|
||||||
awk -F '\t' -v want="$name" '$1 == want { found=1 } END { exit !found }' \
|
|
||||||
<<<"$want_pairs" && continue
|
|
||||||
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
||||||
if [ -z "$id" ]; then missing+=("$name"); continue; fi
|
|
||||||
want_ids+=("$id")
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
# An add-label the repo does not carry refuses BEFORE the write. A PUT
|
|
||||||
# that silently dropped an unresolvable name would remove a label nobody
|
|
||||||
# asked to remove — a destructive write dressed as a partial success.
|
|
||||||
if [ "${#missing[@]}" -gt 0 ]; then
|
|
||||||
echo "forge_issue_edit: #$n: no label id on $REPO for: ${missing[*]} — refusing to PUT a set that would drop it" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# NOTHING TO CHANGE, NOTHING TO WRITE. The reconcilers call
|
|
||||||
# --remove-label unconditionally to converge state, so most calls here ask
|
|
||||||
# to remove a label the issue does not carry. Writing the unchanged set
|
|
||||||
# back would open the read-modify-write window of ceremony#128 for no
|
|
||||||
# state change at all; the GET above is already the proof the sweep
|
|
||||||
# reached the forge (@codex-reviewer-andresmgsl). gh's own behaviour on an
|
|
||||||
# absent --remove-label is likewise a no-op.
|
|
||||||
local current_ids
|
|
||||||
current_ids="$(printf '%s\n' "$current" | grep -v '^$' | cut -f2 | sort -n | tr '\n' ' ')"
|
|
||||||
if [ "$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} | grep -v '^$' | sort -n | tr '\n' ' ')" = "$current_ids" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
payload="$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} \
|
|
||||||
| jq -R 'select(. != "") | tonumber' | jq -sc '{labels: .}')"
|
|
||||||
forgejo_write PUT "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
|
||||||
elif [ "${#add_labels[@]}" -gt 0 ]; then
|
|
||||||
local payload
|
|
||||||
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
|
|
||||||
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
|
|
||||||
local current want payload
|
|
||||||
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
|
|
||||||
want="$(
|
|
||||||
{
|
|
||||||
printf '%s\n' "$current"
|
|
||||||
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
|
|
||||||
} | grep -v '^$' | sort -u
|
|
||||||
)"
|
|
||||||
if [ "${#rm_assignees[@]}" -gt 0 ]; then
|
|
||||||
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
|
|
||||||
fi
|
|
||||||
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
|
|
||||||
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_issue_comment() {
|
|
||||||
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
|
||||||
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_pr_list() {
|
|
||||||
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
|
|
||||||
# machine reads, assembled from the two places Forgejo keeps it. The rollup
|
|
||||||
# is mapped into the node shape checks_state already parses, so the decision
|
|
||||||
# code is untouched.
|
|
||||||
forge_pr_view() {
|
|
||||||
local n="${1:?forge_pr_view: number required}" pr sha status
|
|
||||||
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
|
|
||||||
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
|
|
||||||
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
|
|
||||||
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
|
||||||
jq -n --argjson pr "$pr" --argjson st "$status" '
|
|
||||||
{
|
|
||||||
# Forgejo folds checking, conflict, check error, and WIP into false.
|
|
||||||
# Draft must win because WIP makes the boolean carry no merge result (#236).
|
|
||||||
mergeable: (if $pr.draft == true then "UNKNOWN"
|
|
||||||
elif $pr.mergeable == true then "MERGEABLE"
|
|
||||||
elif $pr.merge_base == $pr.base.sha then "UNKNOWN"
|
|
||||||
else "CONFLICTING" end),
|
|
||||||
statusCheckRollup: [
|
|
||||||
$st.statuses[]? | {
|
|
||||||
__typename: "StatusContext",
|
|
||||||
context: .context,
|
|
||||||
# Forgejo carries the workflow name only as the context prefix;
|
|
||||||
# no separator means no proven workflow, so never guess (#243).
|
|
||||||
workflowName: ((.context // "")
|
|
||||||
| if contains(" / ") then split(" / ")[0] else "" end),
|
|
||||||
state: (.status | ascii_upcase),
|
|
||||||
# checks_state groups repeated contexts and takes the NEWEST by
|
|
||||||
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
|
|
||||||
# winner would be decided by incidental array order, so a stale
|
|
||||||
# re-run could outrank the live verdict (#4743). The combined
|
|
||||||
# status carries both fields; measured on this instance.
|
|
||||||
createdAt: .created_at,
|
|
||||||
completedAt: .updated_at
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_review_requests <n> — logins with a live review request.
|
|
||||||
# Forgejo review.go deletes REQUEST_REVIEW rows when the reviewer submits any
|
|
||||||
# review, so these rows are the exact live set rather than review history (#238).
|
|
||||||
forge_pr_review_requests() {
|
|
||||||
local n="${1:?forge_pr_review_requests: number required}"
|
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/reviews" \
|
|
||||||
--jq '.[] | select(.state == "REQUEST_REVIEW") | .user.login' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
|
||||||
|
|
||||||
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
|
|
||||||
# bootstrap_labels creates every declared label on every workflow_dispatch, so
|
|
||||||
# the second dispatch must update rather than conflict; a plain POST onto an
|
|
||||||
# existing name aborts the bootstrap under set -e.
|
|
||||||
forge_label_create() {
|
|
||||||
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
|
|
||||||
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
|
|
||||||
ids="$(forgejo_label_ids)" || return 1
|
|
||||||
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
||||||
if [ -n "$id" ]; then
|
|
||||||
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
|
|
||||||
else
|
|
||||||
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_delete() {
|
|
||||||
local name="${1:?}" ids id
|
|
||||||
ids="$(forgejo_label_ids)" || return 1
|
|
||||||
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
||||||
[ -n "$id" ] || return 0
|
|
||||||
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
|
|
||||||
# here so a non-2xx is named rather than swallowed, the same contract
|
|
||||||
# forgejo_http_ok gives reads.
|
|
||||||
forgejo_write() {
|
|
||||||
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
|
|
||||||
base="$(forgejo_api_base)" || return 1
|
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
||||||
hdr="$(mktemp)"; body="$(mktemp)"
|
|
||||||
if [ -n "$payload" ]; then
|
|
||||||
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" -H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" "$base/$endpoint"
|
|
||||||
else
|
|
||||||
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" "$base/$endpoint"
|
|
||||||
fi
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
rm -f "$hdr" "$body"
|
|
||||||
echo "forge: $method $endpoint failed to send" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
|
|
||||||
head -c 300 "$body" >&2; echo >&2
|
|
||||||
rm -f "$hdr" "$body"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
cat "$body"
|
|
||||||
rm -f "$hdr" "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_labels_add <n> <label…> — the additive label write (ceremony#128; see
|
|
||||||
# the github twin). POST /issues/{n}/labels adds the named labels and removes
|
|
||||||
# nothing, and it takes NAMES — measured, unlike the removal path, which
|
|
||||||
# needs ids.
|
|
||||||
forge_labels_add() {
|
|
||||||
local n="${1:?forge_labels_add: number required}"
|
|
||||||
shift
|
|
||||||
[ "$#" -gt 0 ] || return 0
|
|
||||||
forgejo_write POST "repos/$REPO/issues/$n/labels" \
|
|
||||||
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
|
||||||
#
|
|
||||||
# This endpoint DOES exist here, contrary to an earlier reading of mine
|
|
||||||
# (#4698) which recorded requested_reviewers as having no sub-resource at
|
|
||||||
# all. What is true is narrower: Forgejo serves POST and DELETE on it and no
|
|
||||||
# GET, so a GET probe answers 404 — and a POST naming a user who does not
|
|
||||||
# exist answers 404 as well, for a different reason. Measured on a scratch
|
|
||||||
# repo: POST with a real user who lacks read access is 422 ("Reviewer can't
|
|
||||||
# read"), and 201 once they have it.
|
|
||||||
#
|
|
||||||
# The READ stays retired regardless (term 4): the field is stale here even on
|
|
||||||
# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the
|
|
||||||
# current head SHA. It is the write that has an answer.
|
|
||||||
forge_request_reviewer() {
|
|
||||||
local n="${1:?}" user="${2:?}"
|
|
||||||
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
|
|
||||||
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_timeline <n> — JSON array of timeline events projected into the
|
|
||||||
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
|
|
||||||
#
|
|
||||||
# | | GitHub | Forgejo |
|
|
||||||
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
|
|
||||||
# | add vs remove | the two event names | .body "1" / "" |
|
|
||||||
# | actor | .actor.login (no .user) | .user.login (no .actor) |
|
|
||||||
#
|
|
||||||
# Status is captured BEFORE jq so an unreadable read cannot report as an
|
|
||||||
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
|
||||||
forge_timeline() {
|
|
||||||
local n="${1:?forge_timeline: number required}" raw
|
|
||||||
# Measured on this instance: limit=10 reports x-total-count=10 and limit=50
|
|
||||||
# reports 50, while crew!96 held 151 events and strict pagination returned
|
|
||||||
# only its first 50. No other measured endpoint echoes its page size this
|
|
||||||
# way. Timelines are append-only, so exhaustion can include concurrent new
|
|
||||||
# events but cannot create a deletion hole; that is why only this call site
|
|
||||||
# may bypass the header-bound completeness proof (#240).
|
|
||||||
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
|
|
||||||
jq '
|
|
||||||
[.[]
|
|
||||||
| select(.type == "label")
|
|
||||||
| {
|
|
||||||
event: (if .body == "1" then "labeled" else "unlabeled" end),
|
|
||||||
actor: {login: (.user.login // "")},
|
|
||||||
label: {name: (.label.name // "")},
|
|
||||||
created_at: .created_at
|
|
||||||
}
|
|
||||||
]
|
|
||||||
' <<<"$raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
|
|
||||||
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
|
|
||||||
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
|
|
||||||
# with comments_count > 0 are fetched, so a board with none costs zero
|
|
||||||
# extra requests.
|
|
||||||
forge_pr_activity() {
|
|
||||||
local n="${1:?forge_pr_activity: number required}" reviews rid
|
|
||||||
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
|
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
|
||||||
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
|
|
||||||
while IFS= read -r rid; do
|
|
||||||
[ -n "$rid" ] || continue
|
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
|
|
||||||
--jq '.[].created_at' || return 1
|
|
||||||
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the release door's facts (#191) --------------------------------------
|
|
||||||
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
|
|
||||||
# both distinguish "the read completed and the answer is no" from "the read
|
|
||||||
# did not complete" — the distinction lib/facts.sh got wrong before #191,
|
|
||||||
# where any failure became a definite `no` and a release ceremony was
|
|
||||||
# silently demoted to a bare push.
|
|
||||||
#
|
|
||||||
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
|
|
||||||
#
|
|
||||||
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
|
|
||||||
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
|
|
||||||
#
|
|
||||||
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
|
|
||||||
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
|
|
||||||
# ...on a commit with no PR -> 404 {"message":"pull request
|
|
||||||
# does not exist …"}
|
|
||||||
#
|
|
||||||
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
|
|
||||||
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
|
|
||||||
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
|
|
||||||
# for both forges, which is the whole point of the shim.
|
|
||||||
|
|
||||||
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
|
|
||||||
# status on stdout. Separate from forge_api because these two call sites
|
|
||||||
# must SEE a 404 rather than have it collapsed into a failure.
|
|
||||||
forgejo_read_code() {
|
|
||||||
local endpoint="$1" body="$2" base token hdr rc
|
|
||||||
base="$(forgejo_api_base)" || return 1
|
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
||||||
hdr="$(mktemp)"
|
|
||||||
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
rm -f "$hdr"
|
|
||||||
echo "forge: GET $endpoint failed to send" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
|
|
||||||
rm -f "$hdr"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_release_exists <tag> — prints `yes` for a published release and `no`
|
|
||||||
# for a draft or a 404. A non-zero exit means the read did not complete and
|
|
||||||
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
|
|
||||||
forge_release_exists() {
|
|
||||||
local tag="${1:?forge_release_exists: tag required}" body code draft
|
|
||||||
body="$(mktemp)"
|
|
||||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
|
||||||
case "$code" in
|
|
||||||
2*)
|
|
||||||
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|
|
||||||
|| [[ "$draft" != true && "$draft" != false ]]; then
|
|
||||||
rm -f "$body"
|
|
||||||
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
[ "$draft" = true ] && echo no || echo yes
|
|
||||||
;;
|
|
||||||
404) echo no ;;
|
|
||||||
*)
|
|
||||||
rm -f "$body"
|
|
||||||
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
rm -f "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
|
||||||
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
|
|
||||||
# found nothing; a non-zero exit is a read that did not complete.
|
|
||||||
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
|
|
||||||
#
|
|
||||||
# THE FOURTH ASYMMETRY (#209), measured 2026-08-05:
|
|
||||||
#
|
|
||||||
# GET /repos/{o}/{r}/commits/{sha} -> 404 (200 on GitHub)
|
|
||||||
# GET /repos/{o}/{r}/git/commits/{sha} -> 200 date under `.created`
|
|
||||||
#
|
|
||||||
# Found by the first post-merge sweep after the 0.6.0 merge, not by review:
|
|
||||||
# #198 ported this call site onto the shim with GitHub's path unchanged, and
|
|
||||||
# the block it lives in had never executed here before. Every sweep printed
|
|
||||||
# `could not read the head commit's date` and left blocker:unrequested
|
|
||||||
# unjudged.
|
|
||||||
#
|
|
||||||
# `.created` and not `.commit.committer.date`: the /git/commits payload is the
|
|
||||||
# git object, whose top-level `created` is the committer date. The verb hides
|
|
||||||
# both differences so the caller keeps asking for one timestamp.
|
|
||||||
forge_commit_at() {
|
|
||||||
local sha="${1:?forge_commit_at: sha required}"
|
|
||||||
forge_api "repos/$REPO/git/commits/$sha" --jq '.created'
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_commit_pulls() {
|
|
||||||
local sha="${1:?forge_commit_pulls: sha required}" body code out
|
|
||||||
body="$(mktemp)"
|
|
||||||
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
|
|
||||||
case "$code" in
|
|
||||||
2*)
|
|
||||||
# One object -> a one-element array, so the call site's jq is the
|
|
||||||
# same expression it runs against GitHub.
|
|
||||||
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
|
|
||||||
rm -f "$body"
|
|
||||||
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$out"
|
|
||||||
;;
|
|
||||||
404) printf '[]\n' ;;
|
|
||||||
*)
|
|
||||||
rm -f "$body"
|
|
||||||
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
rm -f "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the release door's writes (#191) -------------------------------------
|
|
||||||
# Confirmed against this instance's own swagger, 2026-08-04:
|
|
||||||
#
|
|
||||||
# POST /repos/{o}/{r}/tags -> exists (tag creation)
|
|
||||||
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
|
|
||||||
# POST /repos/{o}/{r}/releases -> exists
|
|
||||||
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
|
|
||||||
#
|
|
||||||
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
|
|
||||||
# /git/refs; Forgejo does not serve POST there at all and creates tags at
|
|
||||||
# /tags instead. A 1:1 port of the gh call would 404 forever.
|
|
||||||
|
|
||||||
# forge_tag_create <tag> <sha>
|
|
||||||
forge_tag_create() {
|
|
||||||
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
|
||||||
forgejo_write POST "repos/$REPO/tags" \
|
|
||||||
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already
|
|
||||||
# a hard dependency of this backend, and @uri is its one correct answer; a
|
|
||||||
# hand-rolled sed class is how the next unescaped character gets through.
|
|
||||||
forgejo_urlencode() {
|
|
||||||
jq -rn --arg s "${1-}" '$s|@uri'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
|
|
||||||
# post-create failure. The caller has already printed the original failure;
|
|
||||||
# cleanup can add evidence but must never replace that diagnosis (#271).
|
|
||||||
forgejo_release_cleanup_draft() {
|
|
||||||
local id="${1:?forgejo_release_cleanup_draft: id required}"
|
|
||||||
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
|
|
||||||
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
|
|
||||||
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
|
|
||||||
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
|
|
||||||
# uploads every asset, then publishes it. Any failure after create removes the
|
|
||||||
# draft, so the function leaves either a complete published release or nothing.
|
|
||||||
forge_release_create() {
|
|
||||||
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
|
||||||
local notes="${3:?forge_release_create: notes file required}" out id base token
|
|
||||||
local existing code draft existing_id
|
|
||||||
shift 3
|
|
||||||
|
|
||||||
# A previous rollback whose DELETE failed must not wedge the next attempt.
|
|
||||||
# Remove only a draft for this exact tag; a published release is never
|
|
||||||
# touched and its create will retain Forgejo's ordinary conflict refusal.
|
|
||||||
existing="$(mktemp)"
|
|
||||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
|
|
||||||
case "$code" in
|
|
||||||
2*)
|
|
||||||
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|
|
||||||
|| [[ "$draft" != true && "$draft" != false ]]; then
|
|
||||||
rm -f "$existing"
|
|
||||||
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [ "$draft" = true ]; then
|
|
||||||
existing_id="$(jq -r '.id // empty' "$existing")"
|
|
||||||
if [ -z "$existing_id" ]; then
|
|
||||||
rm -f "$existing"
|
|
||||||
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
|
|
||||||
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
|
|
||||||
rm -f "$existing"
|
|
||||||
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
404) ;;
|
|
||||||
*)
|
|
||||||
rm -f "$existing"
|
|
||||||
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
rm -f "$existing"
|
|
||||||
|
|
||||||
out="$(forgejo_write POST "repos/$REPO/releases" \
|
|
||||||
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
|
||||||
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
|
|
||||||
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
|
||||||
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
|
||||||
if ! base="$(forgejo_api_base)"; then
|
|
||||||
forgejo_release_cleanup_draft "$id" "$tag"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
||||||
local f name
|
|
||||||
for f in "$@"; do
|
|
||||||
[ -e "$f" ] || continue
|
|
||||||
# The asset name is a QUERY VALUE, and the hook contract permits any
|
|
||||||
# file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke
|
|
||||||
# on a space (curl exits 3 on the malformed URL) and silently changed
|
|
||||||
# the name on '&', '#', '+' and '%' — `gh release create` handled those,
|
|
||||||
# so a 1:1 port had to as well (#191, found by @codex on !193).
|
|
||||||
name="$(forgejo_urlencode "$(basename "$f")")"
|
|
||||||
curl -sS -f -X POST -H "Authorization: token $token" \
|
|
||||||
-F "attachment=@$f" \
|
|
||||||
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|
|
||||||
|| {
|
|
||||||
echo "forge_release_create: asset upload failed for '$f'" >&2
|
|
||||||
forgejo_release_cleanup_draft "$id" "$tag"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
done
|
|
||||||
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
|
|
||||||
forgejo_release_cleanup_draft "$id" "$tag"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
|
||||||
# label IDs, not names (the same asymmetry the issue-label writes carry), so
|
|
||||||
# the names are resolved first through forgejo_label_ids.
|
|
||||||
forge_pr_create() {
|
|
||||||
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
|
||||||
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
|
||||||
shift 4
|
|
||||||
local ids='[]' map name id
|
|
||||||
if [ "$#" -gt 0 ]; then
|
|
||||||
map="$(forgejo_label_ids)" || return 1
|
|
||||||
ids='['
|
|
||||||
for name in "$@"; do
|
|
||||||
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
|
|
||||||
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
|
|
||||||
ids="$ids$id,"
|
|
||||||
done
|
|
||||||
ids="${ids%,}]"
|
|
||||||
fi
|
|
||||||
forgejo_write POST "repos/$REPO/pulls" \
|
|
||||||
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
|
|
||||||
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
|
|
||||||
# lib/forge.sh when forge_detect says github; never at the same time as the
|
|
||||||
# forgejo backend — they define the same verbs on purpose.
|
|
||||||
#
|
|
||||||
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
|
|
||||||
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
|
|
||||||
# way to keep that true is for every verb here to be a thin pass-through to
|
|
||||||
# the `gh` invocation the call site used before the port. No behaviour is
|
|
||||||
# added, fixed or tidied on this path; anything that looks like an
|
|
||||||
# improvement here is a regression risk against a forge nobody is currently
|
|
||||||
# reporting bugs on.
|
|
||||||
|
|
||||||
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
|
||||||
#
|
|
||||||
# The one deliberate difference from a pure pass-through: the caller no
|
|
||||||
# longer names a page size, because the page-size parameter is not portable
|
|
||||||
# and is therefore the backend's to own (#188).
|
|
||||||
#
|
|
||||||
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
|
||||||
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
|
||||||
#
|
|
||||||
# Both answer HTTP 200 either way, so a call site that names one is a silent
|
|
||||||
# truncation waiting for the other forge. per_page=100 is injected here —
|
|
||||||
# exactly what the call sites said before — so the GitHub path is unchanged
|
|
||||||
# in behaviour while the parameter stops being a call-site concern.
|
|
||||||
forge_api() {
|
|
||||||
local paginate=false endpoint="" jqexpr="" have_jq=false
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--paginate) paginate=true ;;
|
|
||||||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
|
||||||
|
|
||||||
if [ "$paginate" = true ]; then
|
|
||||||
endpoint="$(github_page_url "$endpoint")"
|
|
||||||
if [ "$have_jq" = true ]; then
|
|
||||||
gh api --paginate "$endpoint" --jq "$jqexpr"
|
|
||||||
else
|
|
||||||
gh api --paginate "$endpoint"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ "$have_jq" = true ]; then
|
|
||||||
gh api "$endpoint" --jq "$jqexpr"
|
|
||||||
else
|
|
||||||
gh api "$endpoint"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# github_page_url <endpoint> — pure, so the page-size contract is testable
|
|
||||||
# without a network. Strips any page-size parameter a caller left behind in
|
|
||||||
# either dialect, then applies GitHub's own.
|
|
||||||
github_page_url() {
|
|
||||||
local endpoint="${1:?github_page_url: endpoint required}" clean
|
|
||||||
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
||||||
case "$clean" in
|
|
||||||
*\?) printf '%sper_page=100\n' "$clean" ;;
|
|
||||||
*\?*) printf '%s&per_page=100\n' "$clean" ;;
|
|
||||||
*) printf '%s?per_page=100\n' "$clean" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
|
|
||||||
# Every one of these is the exact `gh` invocation the call site carried
|
|
||||||
# before the port. Term 5 is kept by making this file boring.
|
|
||||||
|
|
||||||
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
|
|
||||||
# or a PR (gh treats them interchangeably, and so do the call sites).
|
|
||||||
forge_issue_edit() {
|
|
||||||
local n="${1:?forge_issue_edit: number required}"
|
|
||||||
shift
|
|
||||||
gh issue edit "$n" -R "$REPO" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_issue_comment <n> <body>
|
|
||||||
forge_issue_comment() {
|
|
||||||
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
|
||||||
gh issue comment "$n" -R "$REPO" --body "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_list — open PR numbers, one per line. Note this used
|
|
||||||
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
|
|
||||||
# URL-parameter strip could have caught, so it moves behind the shim with
|
|
||||||
# the rest (#188).
|
|
||||||
forge_pr_list() {
|
|
||||||
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
|
|
||||||
# with the reason on stderr. `gh pr view` rather than the REST PR object:
|
|
||||||
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
|
|
||||||
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
|
|
||||||
forge_pr_view() {
|
|
||||||
local n="${1:?forge_pr_view: number required}"
|
|
||||||
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_review_requests <n> — logins with a live review request.
|
|
||||||
forge_pr_review_requests() {
|
|
||||||
local n="${1:?forge_pr_review_requests: number required}"
|
|
||||||
forge_api "repos/$REPO/pulls/$n" --jq '.requested_reviewers[].login' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_label_list — every label name in the repo.
|
|
||||||
forge_label_list() {
|
|
||||||
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_create() {
|
|
||||||
local name="${1:?}" color="${2:?}" desc="${3:-}"
|
|
||||||
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_delete() {
|
|
||||||
local name="${1:?}"
|
|
||||||
gh label delete "$name" -R "$REPO" --yes
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
|
|
||||||
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
|
|
||||||
# labeler action computed (labels-at-job-start ∪ derived) and PUT the whole
|
|
||||||
# set, so a label applied while the job ran was silently removed. This is the
|
|
||||||
# raw POST, which adds the named labels, ignores ones already present, and
|
|
||||||
# removes nothing — a concurrent label survives by construction.
|
|
||||||
forge_labels_add() {
|
|
||||||
local n="${1:?forge_labels_add: number required}" args=() label
|
|
||||||
shift
|
|
||||||
for label in "$@"; do args+=(-f "labels[]=$label"); done
|
|
||||||
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
|
||||||
forge_request_reviewer() {
|
|
||||||
local n="${1:?}" user="${2:?}"
|
|
||||||
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_timeline <n> — JSON array of timeline events in the GitHub shape
|
|
||||||
# (.event, .actor.login, .label.name, .created_at). The GitHub path is a
|
|
||||||
# pass-through: that shape is what the forge already returns (#188 batch).
|
|
||||||
# Callers must capture the status of THIS function before piping into jq —
|
|
||||||
# a pipeline's status is the last command's, so `forge_timeline | jq`
|
|
||||||
# collapses an unreadable timeline into an empty one (#4853).
|
|
||||||
forge_timeline() {
|
|
||||||
local n="${1:?forge_timeline: number required}"
|
|
||||||
forge_api --paginate "repos/$REPO/issues/$n/timeline"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity
|
|
||||||
# (issue comments, inline review comments, commits). GitHub serves the
|
|
||||||
# flat /pulls/{n}/comments collection; the forgejo twin re-derives it from
|
|
||||||
# reviews with comments_count > 0 because that endpoint 404s there (#4844).
|
|
||||||
forge_pr_activity() {
|
|
||||||
local n="${1:?forge_pr_activity: number required}"
|
|
||||||
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
|
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
|
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the release door's facts (#191) --------------------------------------
|
|
||||||
# The github twins of the forgejo backend's two release-door reads. Term 5
|
|
||||||
# discipline applies: these are the `gh` calls lib/facts.sh carried before
|
|
||||||
# the port, with one behaviour added — a read that did not complete is
|
|
||||||
# reported as such instead of collapsing into a definite `no`.
|
|
||||||
|
|
||||||
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
|
|
||||||
# read did not complete and the answer is UNKNOWN (#191).
|
|
||||||
forge_release_exists() {
|
|
||||||
local tag="${1:?forge_release_exists: tag required}" errf err rc
|
|
||||||
errf="$(mktemp)"
|
|
||||||
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
|
|
||||||
rm -f "$errf"
|
|
||||||
echo yes
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
rc=$?
|
|
||||||
err="$(cat "$errf")"; rm -f "$errf"
|
|
||||||
# gh's 404 text is stable and is the only failure that is an ANSWER.
|
|
||||||
case "$err" in
|
|
||||||
*"HTTP 404"*) echo no; return 0 ;;
|
|
||||||
esac
|
|
||||||
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
|
||||||
# a JSON array. GitHub serves the array directly; the forgejo twin builds
|
|
||||||
# one from its single-object endpoint so this call site is identical.
|
|
||||||
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
|
|
||||||
#
|
|
||||||
# A VERB rather than a path at the call site, because the two forges do not
|
|
||||||
# agree on where a single commit lives: GitHub serves it at /commits/{sha},
|
|
||||||
# Forgejo 404s there and serves it at /git/commits/{sha} with the timestamp
|
|
||||||
# under a different field (#209). The caller wants one timestamp; it should not
|
|
||||||
# have to know either shape.
|
|
||||||
forge_commit_at() {
|
|
||||||
local sha="${1:?forge_commit_at: sha required}"
|
|
||||||
forge_api "repos/$REPO/commits/$sha" --jq '.commit.committer.date'
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_commit_pulls() {
|
|
||||||
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
|
|
||||||
errf="$(mktemp)"
|
|
||||||
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
|
|
||||||
rm -f "$errf"
|
|
||||||
printf '%s\n' "$out"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
rc=$?
|
|
||||||
err="$(cat "$errf")"; rm -f "$errf"
|
|
||||||
case "$err" in
|
|
||||||
*"HTTP 404"*) printf '[]\n'; return 0 ;;
|
|
||||||
esac
|
|
||||||
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the release door's writes (#191) -------------------------------------
|
|
||||||
# The gh calls the workflow carried before the port, moved behind the shim
|
|
||||||
# so the call sites stop naming a client. Term 5: same flags, same order.
|
|
||||||
|
|
||||||
# forge_tag_create <tag> <sha>
|
|
||||||
forge_tag_create() {
|
|
||||||
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
|
||||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_release_create <tag> <title> <notes-file> [asset…]
|
|
||||||
forge_release_create() {
|
|
||||||
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
|
||||||
local notes="${3:?forge_release_create: notes file required}"
|
|
||||||
shift 3
|
|
||||||
gh release create "$tag" --verify-tag --title "$title" \
|
|
||||||
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
|
|
||||||
# bump-fallback PR (#191). gh takes repeated --label flags.
|
|
||||||
forge_pr_create() {
|
|
||||||
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
|
||||||
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
|
||||||
shift 4
|
|
||||||
local args=() l
|
|
||||||
for l in "$@"; do args+=(--label "$l"); done
|
|
||||||
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
|
|
||||||
--title "$title" --body "$body" "${args[@]}"
|
|
||||||
}
|
|
||||||
224
lib/forge.sh
224
lib/forge.sh
|
|
@ -1,224 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/forge.sh — one forge abstraction, two backends (issue #188).
|
|
||||||
#
|
|
||||||
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
|
|
||||||
# own shell options, exactly as lib/version.sh does. This file is the
|
|
||||||
# selector only; the backends live beside it in lib/forge-github.sh and
|
|
||||||
# lib/forge-forgejo.sh, and nothing here talks to a network.
|
|
||||||
#
|
|
||||||
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
|
|
||||||
# all the way down — 61 runtime call sites, no indirection, no forge check.
|
|
||||||
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
|
|
||||||
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
|
|
||||||
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
|
|
||||||
#
|
|
||||||
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
|
|
||||||
# to derive" — the file exists (HTTP 200)
|
|
||||||
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
|
|
||||||
# issueflow-reconcile exit 1 "unexpected end of JSON input"
|
|
||||||
#
|
|
||||||
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
|
|
||||||
# own blind-sweep warning (#96) could not fire, because it counts unreadable
|
|
||||||
# PRs against a list `gh pr list` never produced — and a process
|
|
||||||
# substitution's failure does not trip set -e, so `total` stayed 0 and the
|
|
||||||
# sweep called itself reconciled. rig run 979 is the log.
|
|
||||||
#
|
|
||||||
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
|
|
||||||
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
|
|
||||||
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
|
|
||||||
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
|
|
||||||
# prints nothing, exits into the same empty loop) and all three actions go
|
|
||||||
# green while reading nothing. That is this repo's own doctrine — an
|
|
||||||
# unreadable rollup reads as "nothing is failing" — being violated by the
|
|
||||||
# repo that wrote it.
|
|
||||||
#
|
|
||||||
# So: the forge is decided ONCE, before any sweep, and a client that cannot
|
|
||||||
# speak it refuses loudly. Never "probably github".
|
|
||||||
|
|
||||||
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
|
|
||||||
# tell. Order matters and every signal below was measured, not read from
|
|
||||||
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
|
|
||||||
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
|
|
||||||
# runner's is the control.
|
|
||||||
#
|
|
||||||
# The trap that makes this non-obvious: **the Forgejo runner populates the
|
|
||||||
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
|
|
||||||
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
|
|
||||||
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
|
|
||||||
# precisely the bug. What actually differs:
|
|
||||||
#
|
|
||||||
# signal GitHub Forgejo (measured)
|
|
||||||
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
|
|
||||||
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
|
|
||||||
# GITEA_ACTIONS (unset) true
|
|
||||||
#
|
|
||||||
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
|
|
||||||
# forge telling us the two `gh api graphql` sites #188 retired can never
|
|
||||||
# work here. It is deliberately NOT a detection signal, though: an empty
|
|
||||||
# variable is also what a hand-rolled harness leaves behind, and a signal
|
|
||||||
# that fires on absence is a signal that fires by accident.
|
|
||||||
forge_detect() {
|
|
||||||
# 1. The explicit override outranks every probe — the escape hatch for a
|
|
||||||
# forge this file has not met, and the handle the tests drive. A typo
|
|
||||||
# in it is fatal on purpose: the operator said something and it was
|
|
||||||
# wrong, and falling through to a probe that guesses right by accident
|
|
||||||
# would hide that until the guess was wrong too.
|
|
||||||
if [ -n "${CEREMONY_FORGE:-}" ]; then
|
|
||||||
case "$CEREMONY_FORGE" in
|
|
||||||
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
|
|
||||||
*)
|
|
||||||
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
|
|
||||||
# hand-set GITHUB_API_URL might not be, so it is read first.
|
|
||||||
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
|
|
||||||
printf 'forgejo\n'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
|
|
||||||
# Enterprise Server alike — GHES is a github backend on a non-github.com
|
|
||||||
# host, and routing it to the forgejo backend would regress term 5's
|
|
||||||
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
|
|
||||||
# serves.
|
|
||||||
case "${GITHUB_API_URL:-}" in
|
|
||||||
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
|
|
||||||
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
|
|
||||||
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 4. Last resort, the server host. Only github.com itself is conclusive
|
|
||||||
# here: a bare hostname says nothing about which API it serves.
|
|
||||||
case "${GITHUB_SERVER_URL:-}" in
|
|
||||||
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
|
|
||||||
# reinstates the exact blind sweep this file exists to end. Name what
|
|
||||||
# was inspected and the escape hatch, so the log answers "why" without
|
|
||||||
# a second run (#101 D5, one layer up: report, do not diagnose).
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_detect: cannot determine which forge this is — refusing to guess (#188).
|
|
||||||
GITHUB_API_URL='${GITHUB_API_URL:-}'
|
|
||||||
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
|
|
||||||
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
|
|
||||||
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Where the backends live. Captured at source time, not call time: a
|
|
||||||
# function that resolves BASH_SOURCE later would resolve its own file, not
|
|
||||||
# this one.
|
|
||||||
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# forge_select [forge] — source the backend for this forge, so the forge_*
|
|
||||||
# verbs exist. Exactly one backend is ever loaded; both define the same
|
|
||||||
# names, which is what keeps the branching out of the 61 call sites (term 1)
|
|
||||||
# and the single-forge assumption from growing back.
|
|
||||||
#
|
|
||||||
# Idempotent, because the actions call it once and the tests call it per
|
|
||||||
# case. Pass a forge explicitly to load a specific backend; omit it and the
|
|
||||||
# environment decides via forge_detect.
|
|
||||||
forge_select() {
|
|
||||||
local forge="${1:-}"
|
|
||||||
# One default for every consumer: the forgejo backend addresses the repo
|
|
||||||
# through REPO, the github backend reads GITHUB_REPOSITORY. Defaulting
|
|
||||||
# here means no call site — workflow step or script — can forget it and
|
|
||||||
# get a repo-less path (#191). The reconcilers still assert their own.
|
|
||||||
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
|
|
||||||
export REPO
|
|
||||||
if [ -z "$forge" ]; then
|
|
||||||
forge="$(forge_detect)" || return 1
|
|
||||||
fi
|
|
||||||
case "$forge" in
|
|
||||||
github | forgejo) ;;
|
|
||||||
*)
|
|
||||||
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
|
|
||||||
# Read by callers and tests to assert which backend is loaded, so the
|
|
||||||
# choice is inspectable rather than implied by which functions exist.
|
|
||||||
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
|
|
||||||
FORGE="$forge"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_client <forge> — print the client that backend requires.
|
|
||||||
#
|
|
||||||
# github -> gh the current call set, extracted 1:1 (term 5)
|
|
||||||
# forgejo -> rest /api/v1 over curl+jq
|
|
||||||
#
|
|
||||||
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
|
|
||||||
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
|
|
||||||
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
|
|
||||||
# `stoke` on PATH. That second absence is what retired option A from the
|
|
||||||
# ruling: porting the call sites to the stoke CLI would have put a binary
|
|
||||||
# on the critical path that the runner does not have and that would need
|
|
||||||
# installing before every job.
|
|
||||||
forge_client() {
|
|
||||||
case "${1:?forge_client: forge required}" in
|
|
||||||
github) printf 'gh\n' ;;
|
|
||||||
forgejo) printf 'rest\n' ;;
|
|
||||||
*)
|
|
||||||
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
|
|
||||||
# and proves the client can speak it, or exits non-zero with a named reason.
|
|
||||||
#
|
|
||||||
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
|
|
||||||
# how a call site that still hard-codes `gh` announces itself honestly while
|
|
||||||
# the backends are being ported. Two checks run, in order:
|
|
||||||
#
|
|
||||||
# 1. the declaration, when made, must match what this forge needs;
|
|
||||||
# 2. that client's binaries must actually be on PATH — checked whether or
|
|
||||||
# not a declaration was made, because a call site that declares the
|
|
||||||
# right client on a runner that lacks it is still a blind sweep waiting
|
|
||||||
# to happen.
|
|
||||||
forge_preflight() {
|
|
||||||
local forge want
|
|
||||||
forge="$(forge_detect)" || return 1
|
|
||||||
want="$(forge_client "$forge")" || return 1
|
|
||||||
|
|
||||||
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
|
|
||||||
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
|
|
||||||
and has no GraphQL surface at all. Pointing one at the other does not
|
|
||||||
fail usefully — it reads nothing and reports success.
|
|
||||||
This forge needs the '$want' client.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Then prove the tools are actually here — declared or not. A missing
|
|
||||||
# binary is the rig failure verbatim, "line 692: gh: command not found",
|
|
||||||
# and it must be a refusal before the sweep, not a 127 halfway through
|
|
||||||
# one. Checked on BOTH paths deliberately: a call site that declares the
|
|
||||||
# right client on a runner that lacks it is still a blind sweep waiting
|
|
||||||
# to happen.
|
|
||||||
local missing_bins=() bin
|
|
||||||
case "$want" in
|
|
||||||
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
|
|
||||||
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
|
|
||||||
esac
|
|
||||||
if [ "${#missing_bins[@]}" -gt 0 ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
|
|
||||||
Refusing before the sweep: a reconciler that cannot read the board must
|
|
||||||
not report that it reconciled one.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/issue_references.sh — the LOCAL / CROSS reference classifier (#61).
|
|
||||||
#
|
|
||||||
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
|
|
||||||
# options, as lib/closes_references.sh and lib/forge.sh do.
|
|
||||||
#
|
|
||||||
# WHY IT LIVES HERE. It was defined inside actions/issueflow-reconcile's
|
|
||||||
# executable, and lib/closes_references.sh's header recorded the resulting
|
|
||||||
# wart in prose: "DEPENDENCY: issue_references, from issueflow-reconcile.sh".
|
|
||||||
# That was tolerable while the reconciler was its only caller. #199 makes
|
|
||||||
# actions/refs-not-closing a second one, and a composite action cannot source
|
|
||||||
# another action's program to borrow one function — sourcing a reconciler
|
|
||||||
# runs a reconciler.
|
|
||||||
#
|
|
||||||
# So the dependency the comment described is now a file, and both callers
|
|
||||||
# source it the same way. Nothing about the classifier changed.
|
|
||||||
#
|
|
||||||
# A qualified reference belongs to another repository. The whole token is
|
|
||||||
# classified BEFORE any number is extracted, so `rig#112` can never be read
|
|
||||||
# as local `#112` — which is the entire point of the function.
|
|
||||||
|
|
||||||
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
|
||||||
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
|
|
||||||
| awk '
|
|
||||||
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
|
|
||||||
{ print "CROSS\t" $0 }
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/preflight.sh — the merge door's resume decision, pure and exhaustively
|
|
||||||
# tested (issue #273).
|
|
||||||
#
|
|
||||||
# A merge-door run creates the tag before the artifact hook and release. A
|
|
||||||
# failed hook or publish therefore leaves a tag but no release. Re-running the
|
|
||||||
# same merge commit must resume after that irreversible step; a published
|
|
||||||
# release or a tag naming another commit must still refuse.
|
|
||||||
#
|
|
||||||
# Pure: no repository or forge reads. The workflow establishes four facts:
|
|
||||||
#
|
|
||||||
# VER the version being released
|
|
||||||
# MERGE_SHA the commit this door would tag
|
|
||||||
# TAG_SHAS object names returned for the direct and peeled tag refs,
|
|
||||||
# newline-separated; empty means the tag is absent
|
|
||||||
# RELEASED yes|no — whether a published release for VER exists
|
|
||||||
#
|
|
||||||
# Output: resume=yes or resume=no on stdout, notices to stdout, refusals to
|
|
||||||
# stderr, return 1 on refusal.
|
|
||||||
#
|
|
||||||
# The decision table (this IS the spec — issue #273):
|
|
||||||
#
|
|
||||||
# | # | RELEASED | TAG_SHAS contains MERGE_SHA | result |
|
|
||||||
# |---|----------|------------------------------|---------------------------|
|
|
||||||
# | 1 | yes | either | REFUSE: already released |
|
|
||||||
# | 2 | no | empty | resume=no: ordinary run |
|
|
||||||
# | 3 | no | yes | resume=yes + resume NOTICE |
|
|
||||||
# | 4 | no | non-empty, no | REFUSE: tag is elsewhere |
|
|
||||||
|
|
||||||
release_preflight() {
|
|
||||||
local tag_sha sha
|
|
||||||
|
|
||||||
if [ -z "${VER:-}" ]; then
|
|
||||||
printf '%s\n' "VER is empty — the caller failed to establish the release version. Refusing to decide — creating nothing." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [ -z "${MERGE_SHA:-}" ]; then
|
|
||||||
printf '%s\n' "MERGE_SHA is empty — the caller failed to establish the merge commit. Refusing to decide — creating nothing." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [ -z "${RELEASED:-}" ]; then
|
|
||||||
printf '%s\n' "RELEASED is empty — the caller failed to establish whether release '$VER' exists. Refusing to decide — creating nothing." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
case "$RELEASED" in
|
|
||||||
yes | no) ;;
|
|
||||||
*)
|
|
||||||
printf '%s\n' "RELEASED='$RELEASED' — expected yes or no. Refusing to decide — creating nothing." >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Row 1 comes first: deleting a tag under a standing release never makes
|
|
||||||
# that release safe to recreate.
|
|
||||||
if [ "$RELEASED" = yes ]; then
|
|
||||||
printf '%s\n' "release '$VER' already exists — this release already happened; refusing to re-release, creating nothing." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Row 2: an absent tag is the ordinary first run.
|
|
||||||
if [ -z "${TAG_SHAS:-}" ]; then
|
|
||||||
printf '%s\n' 'resume=no'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Row 3: compare each object name as a whole line. For an annotated tag the
|
|
||||||
# direct ref names the tag object and the peeled ref names MERGE_SHA.
|
|
||||||
while IFS= read -r sha; do
|
|
||||||
if [ "$sha" = "$MERGE_SHA" ]; then
|
|
||||||
printf '%s\n' "NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run."
|
|
||||||
printf '%s\n' 'resume=yes'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done <<<"$TAG_SHAS"
|
|
||||||
|
|
||||||
# Row 4: the first object name is enough to diagnose the conflicting tag;
|
|
||||||
# MERGE_SHA is printed beside it so the operator sees both sides.
|
|
||||||
tag_sha="${TAG_SHAS%%$'\n'*}"
|
|
||||||
printf '%s\n' "tag '$VER' already exists at $tag_sha but this run would tag $MERGE_SHA — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
62
lib/read.sh
62
lib/read.sh
|
|
@ -1,62 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/read.sh — the guarded read: an unreadable fact never invents a verdict.
|
|
||||||
#
|
|
||||||
# Both reconcilers source this file. The rule is the family's oldest one
|
|
||||||
# (#101, #95) and it has now been bought twice: the PR surface learned it
|
|
||||||
# when a permissions denial and a network hiccup left byte-identical
|
|
||||||
# evidence, and the ISSUE surface learned it when an HTTP 504 whose body is
|
|
||||||
# GitHub's JSON error object flowed straight into a decision function —
|
|
||||||
# `gh api` prints that body to stdout *and* exits non-zero, so the payload
|
|
||||||
# reaching the guards was valid JSON, `.labels[]` came back empty, and the
|
|
||||||
# sweep wrote `needs-triage` onto a healthy epic and called the pass a
|
|
||||||
# success (crew#329, #247).
|
|
||||||
#
|
|
||||||
# Two helpers:
|
|
||||||
# - guarded_read — run a read, keep its stderr, report its status
|
|
||||||
# - read_failure_reason — render that stderr into one bounded log line
|
|
||||||
#
|
|
||||||
# `read_failure_reason` is called from both surfaces. `guarded_read` is
|
|
||||||
# called from the issue surface only, and that is deliberate rather than
|
|
||||||
# unfinished: labels-reconcile's two capture sites are byte-identical to each
|
|
||||||
# other and predate this file, and converting them is a cleanup #247 does not
|
|
||||||
# own. Do not go looking for a labels-side caller — there is none yet.
|
|
||||||
#
|
|
||||||
# What the CALLER does with a failed read is the caller's: labels-reconcile
|
|
||||||
# leaves the PR alone for the pass, issueflow-reconcile skips the issue. The
|
|
||||||
# one thing neither may do is carry a degraded value into a decision.
|
|
||||||
|
|
||||||
guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STDERR
|
|
||||||
# The status check and the captured stderr are one operation on purpose: a
|
|
||||||
# read whose failure is noticed but whose reason is thrown away is what
|
|
||||||
# #95 had to infer a cause from a control case for — wrongly, it turned
|
|
||||||
# out (#101 D2). Captured into a file rather than merged into stdout, so
|
|
||||||
# an unlucky error line can never be read back as the read's own payload.
|
|
||||||
local __var="$1" __err __out __rc=0
|
|
||||||
shift
|
|
||||||
__err="$(mktemp)" || return 1
|
|
||||||
__out="$("$@" 2>"$__err")" || __rc=$?
|
|
||||||
# shellcheck disable=SC2034 # the out-parameter: every caller reads it beside the status
|
|
||||||
READ_FAILURE_STDERR="$(cat "$__err")"
|
|
||||||
rm -f "$__err"
|
|
||||||
printf -v "$__var" '%s' "$__out"
|
|
||||||
return "$__rc"
|
|
||||||
}
|
|
||||||
|
|
||||||
read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
|
|
||||||
# Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL
|
|
||||||
# blobs. Collapsed so the reason is exactly one log line — a raw newline
|
|
||||||
# inside the captured per-item output block could collide with a matched
|
|
||||||
# string — and truncated because an unbounded paste per item per sweep is
|
|
||||||
# noise, and annotations are capped anyway.
|
|
||||||
local reason
|
|
||||||
reason="$(printf '%s' "${1-}" | tr '\n' ' ')"
|
|
||||||
if [ -z "$reason" ]; then
|
|
||||||
# Empty stderr is itself a fact (D4): a read that failed silently is a
|
|
||||||
# different observation from a denial, and must not read as one.
|
|
||||||
echo "no error output"
|
|
||||||
elif [ "${#reason}" -gt 300 ]; then
|
|
||||||
printf '%s…\n' "${reason:0:300}"
|
|
||||||
else
|
|
||||||
printf '%s\n' "$reason"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
@ -99,40 +99,18 @@ ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoc
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ruling_shape_field_present() { # $1 field label; escalation body on stdin → 0 iff present
|
|
||||||
# THE one spelling of the field-presence test — the escalation selector
|
|
||||||
# scores by it and the shape check grades by it, on purpose in one place:
|
|
||||||
# a selector scoring by one grep while the check grades by another is how
|
|
||||||
# the crew#293 misgrade would come back from the other side (#226).
|
|
||||||
# Line-anchored, allowing leading whitespace and Markdown bold
|
|
||||||
# (`**Options:**` is how the live escalations write them): the labels
|
|
||||||
# appearing only mid-sentence is not the template.
|
|
||||||
local field="$1"
|
|
||||||
grep -Eq "^[[:space:]]*(\*\*)?$field"
|
|
||||||
}
|
|
||||||
|
|
||||||
ruling_shape_score() { # escalation body on stdin → 0–4, one point per field present
|
|
||||||
# The selection rule's metric (#226). An empty body scores 0 through the
|
|
||||||
# same loop — no special case, and never an error.
|
|
||||||
local body field score=0
|
|
||||||
body="$(cat)"
|
|
||||||
for field in "${RULING_SHAPE_FIELDS[@]}"; do
|
|
||||||
if ruling_shape_field_present "$field" <<<"$body"; then score=$((score + 1)); fi
|
|
||||||
done
|
|
||||||
echo "$score"
|
|
||||||
}
|
|
||||||
|
|
||||||
ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED <missing labels>
|
ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED <missing labels>
|
||||||
# Presence only (#50 D4): that `Recommend:` exists is checkable, that the
|
# Presence only (#50 D4): that `Recommend:` exists is checkable, that the
|
||||||
# recommendation is any good is not — no counting options, no parsing the
|
# recommendation is any good is not — no counting options, no parsing the
|
||||||
# prose. The per-field test is ruling_shape_field_present, shared with the
|
# prose. Line-anchored, allowing leading whitespace and Markdown bold
|
||||||
# selector (#226). The `🧭 needs-ruling` header line is deliberately
|
# (`**Options:**` is how the live escalations write them): the labels
|
||||||
# unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a
|
# appearing only mid-sentence is not the template. The `🧭 needs-ruling`
|
||||||
# portability trap for zero enforcement value.
|
# header line is deliberately unchecked — it is prose, and an emoji grep
|
||||||
|
# on an LC_ALL=C runner is a portability trap for zero enforcement value.
|
||||||
local body field missing=""
|
local body field missing=""
|
||||||
body="$(cat)"
|
body="$(cat)"
|
||||||
for field in "${RULING_SHAPE_FIELDS[@]}"; do
|
for field in "${RULING_SHAPE_FIELDS[@]}"; do
|
||||||
ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field"
|
grep -Eq "^[[:space:]]*(\*\*)?$field" <<<"$body" || missing="$missing $field"
|
||||||
done
|
done
|
||||||
if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi
|
if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi
|
||||||
}
|
}
|
||||||
|
|
@ -175,11 +153,8 @@ ruling_default_decision() { # escalation body on stdin → DEADLINE <ts> | HARDB
|
||||||
}
|
}
|
||||||
|
|
||||||
ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
|
ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
|
||||||
# Real activity only, as the caller's surface defines it: the PR sweep
|
# Real activity only — comments, reviews, commits, never label churn, or
|
||||||
# supplies comments, reviews and commits; the issue sweeps supply comments
|
# the sweep would reset its own clock. The nudge needs NO marker: the
|
||||||
# alone — an `assigned` event is the claim clock's fact, and counting it
|
|
||||||
# let a claim silence a pending ruling (#284). Never label churn, or the
|
|
||||||
# sweep would reset its own clock. The nudge needs NO marker: the
|
|
||||||
# nudge comment is itself activity, so posting it resets this window and
|
# nudge comment is itself activity, so posting it resets this window and
|
||||||
# the rule self-rate-limits to at most one nudge per 7 quiet days. That is
|
# the rule self-rate-limits to at most one nudge per 7 quiet days. That is
|
||||||
# deliberate — a later refactor that "fixes" it by adding a marker breaks
|
# deliberate — a later refactor that "fixes" it by adding a marker breaks
|
||||||
|
|
@ -195,32 +170,17 @@ ruling_newest_flag() { # "login<TAB>iso8601" lines on stdin → the newest line
|
||||||
}
|
}
|
||||||
|
|
||||||
ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin
|
ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin
|
||||||
# → "url b64" of the BEST-SHAPED in-window comment by the setter, or
|
# → "url b64" of the EARLIEST in-window comment by the setter, or nothing.
|
||||||
# nothing: highest ruling_shape_score wins, equal scores break to the
|
# Earliest, because the natural shape is escalation-then-flag: the first
|
||||||
# earliest epoch. Earliest-wins outright was the rule until crew#293
|
# qualifying comment is the escalation itself, later ones are follow-ups.
|
||||||
# (2026-08-02): a builder answered its round whole and escalated 33
|
# The body rides along base64-encoded (#73's shape check reads it); rows
|
||||||
# seconds later — both in one window, the reply earlier — and the sweep
|
# without the column still resolve, with an empty body.
|
||||||
# graded the round reply, told a correct escalation it was malformed, and
|
local setter="$1" labeled="$2" login epoch url b64 best_epoch="" best=""
|
||||||
# the setter re-posted a shape it had already met. Score resolves both
|
|
||||||
# orderings; the earliest tiebreak keeps escalation-then-follow-ups
|
|
||||||
# wherever the scores cannot tell candidates apart, including all-zero.
|
|
||||||
# An undecodable or absent body scores 0 and stays a legal candidate —
|
|
||||||
# an unreadable fact never invents a verdict, and never errors the sweep.
|
|
||||||
# The window and the setter gate candidacy before any score is taken.
|
|
||||||
local setter="$1" labeled="$2" login epoch url b64 body score
|
|
||||||
local best_score=-1 best_epoch="" best=""
|
|
||||||
while read -r login epoch url b64; do
|
while read -r login epoch url b64; do
|
||||||
[ -n "$login" ] || continue
|
[ -n "$login" ] || continue
|
||||||
[ "$login" = "$setter" ] || continue
|
[ "$login" = "$setter" ] || continue
|
||||||
ruling_accompanies "$epoch" "$labeled" || continue
|
ruling_accompanies "$epoch" "$labeled" || continue
|
||||||
if body="$(base64 -d <<<"${b64:-}" 2>/dev/null)"; then
|
if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then
|
||||||
score="$(ruling_shape_score <<<"$body")"
|
|
||||||
else
|
|
||||||
score=0
|
|
||||||
fi
|
|
||||||
if [ "$score" -gt "$best_score" ] \
|
|
||||||
|| { [ "$score" -eq "$best_score" ] && [ "$epoch" -lt "$best_epoch" ]; }; then
|
|
||||||
best_score="$score"
|
|
||||||
best_epoch="$epoch"
|
best_epoch="$epoch"
|
||||||
best="$url ${b64:-}"
|
best="$url ${b64:-}"
|
||||||
fi
|
fi
|
||||||
|
|
@ -248,20 +208,13 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# skips BOTH checks — the nudge's specified content links the escalation
|
# skips BOTH checks — the nudge's specified content links the escalation
|
||||||
# comment, which only these facts identify, and half-verdicts on half-read
|
# comment, which only these facts identify, and half-verdicts on half-read
|
||||||
# facts is the exact shape the reconciler's standing rule forbids.
|
# facts is the exact shape the reconciler's standing rule forbids.
|
||||||
# forge_timeline projects both forges into the GitHub event shape
|
local flags newest setter labeled_at labeled_epoch
|
||||||
# (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
|
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
|
||||||
# status is the last command's, so `forge_timeline | jq` would collapse
|
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
|
||||||
# an unreadable timeline into an empty one — the two states this function
|
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
|
||||||
# exists to tell apart (#188 / #4853).
|
|
||||||
local flags newest setter labeled_at labeled_epoch timeline
|
|
||||||
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
|
|
||||||
log "#$n: ruling timeline unreadable — no verdict invented this pass"
|
log "#$n: ruling timeline unreadable — no verdict invented this pass"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
flags="$(jq -r '
|
|
||||||
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
|
|
||||||
| [.actor.login, .created_at] | @tsv
|
|
||||||
' <<<"$timeline")"
|
|
||||||
if [ -z "$flags" ]; then
|
if [ -z "$flags" ]; then
|
||||||
# The label is on the item but no labeled event is visible (a timeline
|
# The label is on the item but no labeled event is visible (a timeline
|
||||||
# hiccup, or an import). Same treatment as unreadable: do nothing.
|
# hiccup, or an import). Same treatment as unreadable: do nothing.
|
||||||
|
|
@ -277,7 +230,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# the whole file is line-oriented, so the row format stays TSV and the
|
# the whole file is line-oriented, so the row format stays TSV and the
|
||||||
# body is decoded at its points of use (#73). Do not switch rows to JSON.
|
# body is decoded at its points of use (#73). Do not switch rows to JSON.
|
||||||
local comments
|
local comments
|
||||||
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
|
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
|
||||||
--jq '.[] | [.user.login, .created_at, .html_url,
|
--jq '.[] | [.user.login, .created_at, .html_url,
|
||||||
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
|
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
|
||||||
log "#$n: ruling comments unreadable — no verdict invented this pass"
|
log "#$n: ruling comments unreadable — no verdict invented this pass"
|
||||||
|
|
@ -315,7 +268,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
|
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
|
||||||
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
|
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
|
||||||
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
|
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
|
||||||
run forge_issue_comment "$n" "$RULING_BARE_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER
|
||||||
The ruling flag on this item was set by @$setter with no accompanying
|
The ruling flag on this item was set by @$setter with no accompanying
|
||||||
escalation comment. Setting it requires the escalation contract — the
|
escalation comment. Setting it requires the escalation contract — the
|
||||||
**question**, the **options**, and a **recommendation** — posted by the
|
**question**, the **options**, and a **recommendation** — posted by the
|
||||||
|
|
@ -346,7 +299,7 @@ still owed." >/dev/null
|
||||||
if [ "$shape" != SHAPED ] \
|
if [ "$shape" != SHAPED ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
|
||||||
local missing="${shape#MALFORMED }"
|
local missing="${shape#MALFORMED }"
|
||||||
run forge_issue_comment "$n" "$RULING_SHAPE_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER
|
||||||
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
|
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
|
||||||
is missing required field labels: **$missing**. The contract's shape is
|
is missing required field labels: **$missing**. The contract's shape is
|
||||||
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
|
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
|
||||||
|
|
@ -374,7 +327,7 @@ enforced." >/dev/null
|
||||||
esac
|
esac
|
||||||
if [ "$rung" = RUNG12 ] \
|
if [ "$rung" = RUNG12 ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
|
||||||
run forge_issue_comment "$n" "$RULING_RUNG12_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER
|
||||||
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
|
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
|
||||||
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
||||||
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
||||||
|
|
@ -389,7 +342,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null
|
||||||
fi
|
fi
|
||||||
if [ "$rung" = RUNG24 ] \
|
if [ "$rung" = RUNG24 ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
|
||||||
run forge_issue_comment "$n" "$RULING_RUNG24_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER
|
||||||
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
|
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
|
||||||
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
||||||
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
||||||
|
|
@ -422,7 +375,7 @@ timer." >/dev/null
|
||||||
else
|
else
|
||||||
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
|
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
|
||||||
fi
|
fi
|
||||||
run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
|
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
|
||||||
|
|
||||||
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
|
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -u
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
source "$ROOT/test/harness.sh"
|
|
||||||
# shellcheck source=lib/attention.sh
|
|
||||||
source "$ROOT/lib/attention.sh"
|
|
||||||
|
|
||||||
check "attention on an unassigned PR is malformed" 0 "MALFORMED_PR" \
|
|
||||||
attention_target_decision pr 0
|
|
||||||
check "attention on an assigned PR is still malformed" 0 "MALFORMED_PR" \
|
|
||||||
attention_target_decision pr 1
|
|
||||||
check "attention on an unassigned issue is malformed" 0 "MALFORMED_UNASSIGNED" \
|
|
||||||
attention_target_decision issue 0
|
|
||||||
check "attention on an assigned issue is healthy" 0 "KEEP" \
|
|
||||||
attention_target_decision issue 1
|
|
||||||
check "an unknown surface is rejected" 2 "" attention_target_decision discussion 0
|
|
||||||
|
|
||||||
check "a malformed PR target is commented" 0 "POST" \
|
|
||||||
attention_comment_decision MALFORMED_PR ""
|
|
||||||
check "an unassigned issue target is commented without precedence" 0 "POST" \
|
|
||||||
attention_comment_decision MALFORMED_UNASSIGNED ""
|
|
||||||
check "claimed-unassigned precedence suppresses the second comment" 0 "SUPPRESS" \
|
|
||||||
attention_comment_decision MALFORMED_UNASSIGNED claimed-unassigned
|
|
||||||
check "post-merge-assigned precedence suppresses the second comment" 0 "SUPPRESS" \
|
|
||||||
attention_comment_decision MALFORMED_UNASSIGNED post-merge-assigned
|
|
||||||
check "a healthy target stays silent" 0 "KEEP" \
|
|
||||||
attention_comment_decision KEEP ""
|
|
||||||
|
|
||||||
check "the newest labeled event defines the episode" 0 "2026-08-03T12:00:00Z" \
|
|
||||||
attention_newest_flag <<'EOF'
|
|
||||||
2026-08-03T10:00:00Z
|
|
||||||
2026-08-03T12:00:00Z
|
|
||||||
2026-08-03T11:00:00Z
|
|
||||||
EOF
|
|
||||||
check "the marker names the label episode" 0 \
|
|
||||||
'<!-- ceremony:attention-malformed:2026-08-03T12:00:00Z -->' \
|
|
||||||
attention_episode_marker 2026-08-03T12:00:00Z
|
|
||||||
|
|
||||||
# Diagnosis is the only write this library may own. Pin the absence of every
|
|
||||||
# label/assignee mutation spelling so a later refactor cannot quietly turn a
|
|
||||||
# report into a repair (#229 D2).
|
|
||||||
check "the attention library contains no issue/PR edit mutation" 1 "" \
|
|
||||||
grep -E 'gh (issue|pr) edit|--(add|remove)-(label|assignee)' "$ROOT/lib/attention.sh"
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -275,61 +275,10 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF'
|
||||||
|
|
||||||
- The shipped entry.
|
- The shipped entry.
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md"
|
printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115.md"
|
||||||
check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
|
check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
|
||||||
in_tree fragments-dev-flat
|
in_tree fragments-dev-flat
|
||||||
|
|
||||||
# The entry length bound (#167) reds the PR that writes the fragment, with
|
|
||||||
# the shared changelog_fragment_problem diagnosis.
|
|
||||||
fragment_tree fragments-dev-over-bound 1.2.4-dev <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 1.2.3 — 2026-07-20
|
|
||||||
|
|
||||||
- The shipped entry.
|
|
||||||
EOF
|
|
||||||
printf -- '- %s\n' \
|
|
||||||
"$(awk 'BEGIN { s = ""; while (length(s) < 301) s = s "a"; print s }')" \
|
|
||||||
>"$TMP/fragments-dev-over-bound/changelog.d/115.md"
|
|
||||||
check "fragment mode refuses an over-bound entry, fragment and length named" 1 \
|
|
||||||
"115.md' has a 301-character entry" \
|
|
||||||
in_tree fragments-dev-over-bound
|
|
||||||
check "fragment mode over-bound refusal names the bound and the split fix" 1 \
|
|
||||||
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
|
|
||||||
in_tree fragments-dev-over-bound
|
|
||||||
|
|
||||||
# The terminal cite (#262) reds the PR that writes the fragment, through the
|
|
||||||
# same shared predicate — which is the whole point of the rule living there
|
|
||||||
# rather than in prose a reviewer has to remember.
|
|
||||||
fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 1.2.3 — 2026-07-20
|
|
||||||
|
|
||||||
- The shipped entry.
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "- An entry that never learned to cite its issue." \
|
|
||||||
>"$TMP/fragments-dev-uncited/changelog.d/115.md"
|
|
||||||
check "fragment mode refuses an uncited entry, fragment named" 1 \
|
|
||||||
"115.md' has an entry with no issue citation" \
|
|
||||||
in_tree fragments-dev-uncited
|
|
||||||
check "fragment mode uncited refusal names the shape to write" 1 \
|
|
||||||
"end it with the issue it comes from: '(#N).'" \
|
|
||||||
in_tree fragments-dev-uncited
|
|
||||||
|
|
||||||
fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 1.2.3 — 2026-07-20
|
|
||||||
|
|
||||||
- The shipped entry.
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "- The citation trails the period. (#115)" \
|
|
||||||
>"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md"
|
|
||||||
check "fragment mode refuses a non-terminal citation, fragment named" 1 \
|
|
||||||
"115.md' has an entry whose issue citation is not terminal" \
|
|
||||||
in_tree fragments-dev-misplaced-cite
|
|
||||||
|
|
||||||
fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
|
fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
@ -342,7 +291,7 @@ EOF
|
||||||
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
|
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Added fragment mode (#115).
|
- Added fragment mode.
|
||||||
EOF
|
EOF
|
||||||
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
|
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
|
||||||
in_tree fragments-dev-grouped
|
in_tree fragments-dev-grouped
|
||||||
|
|
@ -354,11 +303,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
|
||||||
|
|
||||||
- The shipped entry.
|
- The shipped entry.
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
|
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
|
||||||
cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
|
cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Grouped fragment (#115).
|
- Grouped fragment.
|
||||||
EOF
|
EOF
|
||||||
check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \
|
check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \
|
||||||
"fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
|
"fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
|
||||||
|
|
@ -374,7 +323,7 @@ EOF
|
||||||
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
|
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Grouped fragment (#115).
|
- Grouped fragment.
|
||||||
EOF
|
EOF
|
||||||
check "fragment mode refuses an all-grouped set over a flat published section" 1 \
|
check "fragment mode refuses an all-grouped set over a flat published section" 1 \
|
||||||
"changelog.d/115.md' is grouped but newest published section '1.2.3'" \
|
"changelog.d/115.md' is grouped but newest published section '1.2.3'" \
|
||||||
|
|
@ -389,58 +338,11 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
|
||||||
|
|
||||||
- The shipped entry.
|
- The shipped entry.
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
|
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
|
||||||
check "fragment mode refuses a flat set over a grouped published section" 1 \
|
check "fragment mode refuses a flat set over a grouped published section" 1 \
|
||||||
"changelog.d/115.md' is flat but newest published section '1.2.3'" \
|
"changelog.d/115.md' is flat but newest published section '1.2.3'" \
|
||||||
in_tree fragments-dev-flat-over-grouped
|
in_tree fragments-dev-flat-over-grouped
|
||||||
|
|
||||||
# The declared anchor (#182): the flip tree — a grouped set under a
|
|
||||||
# 'grouped' sentinel over a flat published section — is green, where the
|
|
||||||
# same tree minus the sentinel is the all-grouped-over-flat red row above.
|
|
||||||
fragment_tree fragments-dev-flip 1.2.4-dev <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 1.2.3 — 2026-07-20
|
|
||||||
|
|
||||||
- The shipped entry.
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
|
|
||||||
cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF'
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Grouped fragment (#115).
|
|
||||||
EOF
|
|
||||||
check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \
|
|
||||||
"fragment mode" in_tree fragments-dev-flip
|
|
||||||
|
|
||||||
# Post-flip drift is refused on its own PR: a flat probe fragment atop the
|
|
||||||
# flip tree goes red — beside grouped fragments the mix rule names it first.
|
|
||||||
printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
|
|
||||||
check "fragment mode: a flat probe atop the flip tree is refused" 1 \
|
|
||||||
"changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \
|
|
||||||
in_tree fragments-dev-flip
|
|
||||||
rm "$TMP/fragments-dev-flip/changelog.d/116.md"
|
|
||||||
|
|
||||||
# And once the grouped fragments are consumed, the sentinel alone still
|
|
||||||
# holds the shape: an all-flat set under 'grouped' is refused, sentinel
|
|
||||||
# named — the published-section inference never gets a say.
|
|
||||||
rm "$TMP/fragments-dev-flip/changelog.d/115.md"
|
|
||||||
printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
|
|
||||||
check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \
|
|
||||||
"changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \
|
|
||||||
in_tree fragments-dev-flip
|
|
||||||
rm "$TMP/fragments-dev-flip/changelog.d/116.md"
|
|
||||||
|
|
||||||
printf '%s\n' "Grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
|
|
||||||
check "fragment mode: a malformed sentinel is refused, file named" 1 \
|
|
||||||
"'changelog.d/shape' declares neither shape" \
|
|
||||||
in_tree fragments-dev-flip
|
|
||||||
|
|
||||||
printf 'grouped\n\n' >"$TMP/fragments-dev-flip/changelog.d/shape"
|
|
||||||
check "fragment mode: a sentinel with a trailing blank line is refused, file named" 1 \
|
|
||||||
"'changelog.d/shape' declares neither shape" \
|
|
||||||
in_tree fragments-dev-flip
|
|
||||||
|
|
||||||
fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF'
|
fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF'
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
@ -449,7 +351,7 @@ EOF
|
||||||
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
|
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Grouped fragment (#115).
|
- Grouped fragment.
|
||||||
EOF
|
EOF
|
||||||
check "fragment mode accepts a consistent set with no published section" 0 \
|
check "fragment mode accepts a consistent set with no published section" 0 \
|
||||||
"fragment mode" in_tree fragments-dev-no-published
|
"fragment mode" in_tree fragments-dev-no-published
|
||||||
|
|
@ -516,7 +418,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \
|
||||||
"fragment mode" in_tree fragments-bare-stamped
|
"fragment mode" in_tree fragments-bare-stamped
|
||||||
|
|
||||||
cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor"
|
cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor"
|
||||||
printf '%s\n' "- This entry was not consumed (#115)." \
|
printf '%s\n' "- This entry was not consumed." \
|
||||||
>"$TMP/fragments-bare-survivor/changelog.d/115.md"
|
>"$TMP/fragments-bare-survivor/changelog.d/115.md"
|
||||||
check "fragment bare refuses and lists surviving fragments" 1 \
|
check "fragment bare refuses and lists surviving fragments" 1 \
|
||||||
"these fragments were not consumed: changelog.d/115.md" \
|
"these fragments were not consumed: changelog.d/115.md" \
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,13 @@ tree flat-one <<EOF
|
||||||
$BASE_CHANGELOG
|
$BASE_CHANGELOG
|
||||||
EOF
|
EOF
|
||||||
frag flat-one 12.md <<'EOF'
|
frag flat-one 12.md <<'EOF'
|
||||||
- Twelve landed (#12).
|
- Twelve landed.
|
||||||
EOF
|
EOF
|
||||||
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
|
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
|
||||||
in_tree flat-one 0.2.0 2026-07-24
|
in_tree flat-one 0.2.0 2026-07-24
|
||||||
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
|
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
|
||||||
assert_file "$TMP/flat-one/CHANGELOG.md" \
|
assert_file "$TMP/flat-one/CHANGELOG.md" \
|
||||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed (#12).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||||
check "flat: the consumed fragment is deleted" 1 "" \
|
check "flat: the consumed fragment is deleted" 1 "" \
|
||||||
test -e "$TMP/flat-one/changelog.d/12.md"
|
test -e "$TMP/flat-one/changelog.d/12.md"
|
||||||
check "flat: README.md survives consumption" 0 "" \
|
check "flat: README.md survives consumption" 0 "" \
|
||||||
|
|
@ -73,17 +73,17 @@ tree flat-many <<EOF
|
||||||
$BASE_CHANGELOG
|
$BASE_CHANGELOG
|
||||||
EOF
|
EOF
|
||||||
frag flat-many 2.md <<'EOF'
|
frag flat-many 2.md <<'EOF'
|
||||||
- Two (#2).
|
- Two.
|
||||||
EOF
|
EOF
|
||||||
frag flat-many 9.md <<'EOF'
|
frag flat-many 9.md <<'EOF'
|
||||||
- Nine (#9).
|
- Nine.
|
||||||
EOF
|
EOF
|
||||||
frag flat-many 10.md <<'EOF'
|
frag flat-many 10.md <<'EOF'
|
||||||
- Ten (#10).
|
- Ten.
|
||||||
EOF
|
EOF
|
||||||
frag flat-many ceremony-14.md <<'EOF'
|
frag flat-many ceremony-14.md <<'EOF'
|
||||||
- Fourteen crossed over — naïve reflows would mangle this café's
|
- Fourteen crossed over — naïve reflows would mangle this café's
|
||||||
continuation line, so it must survive verbatim (#14).
|
continuation line, so it must survive verbatim.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
assert_check() {
|
assert_check() {
|
||||||
|
|
@ -95,7 +95,7 @@ assert_check() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
|
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
|
||||||
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim (#14).\n- Ten (#10).\n- Nine (#9).\n- Two (#2).'
|
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim.\n- Ten.\n- Nine.\n- Two.'
|
||||||
|
|
||||||
# --- grouped write: canonical order, unnamed group appended ------------------
|
# --- grouped write: canonical order, unnamed group appended ------------------
|
||||||
|
|
||||||
|
|
@ -113,28 +113,28 @@ EOF
|
||||||
frag grouped 21.md <<'EOF'
|
frag grouped 21.md <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed twenty-one (#21).
|
- Fixed twenty-one.
|
||||||
EOF
|
EOF
|
||||||
frag grouped 20.md <<'EOF'
|
frag grouped 20.md <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added twenty (#20).
|
- Added twenty.
|
||||||
- Added twenty, second bullet (#20).
|
- Added twenty, second bullet.
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
- Docs twenty (#20).
|
- Docs twenty.
|
||||||
EOF
|
EOF
|
||||||
frag grouped 19.md <<'EOF'
|
frag grouped 19.md <<'EOF'
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Security nineteen (#19).
|
- Security nineteen.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added nineteen (#19).
|
- Added nineteen.
|
||||||
EOF
|
EOF
|
||||||
GROUPED_BODY=$'### Added\n\n- Added twenty (#20).\n- Added twenty, second bullet (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
|
GROUPED_BODY=$'### Added\n\n- Added twenty.\n- Added twenty, second bullet.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
|
||||||
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
|
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
|
||||||
assert_check grouped "$GROUPED_BODY"
|
assert_check grouped "$GROUPED_BODY"
|
||||||
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
|
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
|
||||||
|
|
@ -143,51 +143,6 @@ check "grouped: the written file is exact" 0 "" \
|
||||||
assert_file "$TMP/grouped/CHANGELOG.md" \
|
assert_file "$TMP/grouped/CHANGELOG.md" \
|
||||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.'
|
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.'
|
||||||
|
|
||||||
# --- the declared anchor: the first grouped release over a flat history ------
|
|
||||||
# The flip ceremony (#182): a 'grouped' sentinel admits grouped fragments
|
|
||||||
# over a flat newest published section, the sentinel is never a stray file,
|
|
||||||
# and it survives the consumption (D5) — the next -dev tree still declares
|
|
||||||
# its shape.
|
|
||||||
|
|
||||||
tree flip <<EOF
|
|
||||||
$BASE_CHANGELOG
|
|
||||||
EOF
|
|
||||||
printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
|
|
||||||
frag flip 40.md <<'EOF'
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Forty landed (#40).
|
|
||||||
EOF
|
|
||||||
check "sentinel: the flip release assembles grouped over a flat published section" 0 \
|
|
||||||
"consumed 1 fragment" in_tree flip 0.2.0 2026-07-24
|
|
||||||
check "sentinel: the written flip section is exact" 0 "" \
|
|
||||||
assert_file "$TMP/flip/CHANGELOG.md" \
|
|
||||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
|
||||||
check "sentinel: changelog.d/shape survives consumption" 0 "" \
|
|
||||||
test -e "$TMP/flip/changelog.d/shape"
|
|
||||||
|
|
||||||
tree flip-flat-frag <<EOF
|
|
||||||
$BASE_CHANGELOG
|
|
||||||
EOF
|
|
||||||
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
|
|
||||||
frag flip-flat-frag 41.md <<'EOF'
|
|
||||||
- Flat forty-one (#41).
|
|
||||||
EOF
|
|
||||||
check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \
|
|
||||||
"changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24
|
|
||||||
|
|
||||||
tree flip-malformed <<EOF
|
|
||||||
$BASE_CHANGELOG
|
|
||||||
EOF
|
|
||||||
printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape"
|
|
||||||
frag flip-malformed 42.md <<'EOF'
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Forty-two (#42).
|
|
||||||
EOF
|
|
||||||
check "sentinel: a malformed sentinel refuses, file named" 1 \
|
|
||||||
"changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24
|
|
||||||
|
|
||||||
# --- a changelog holding only its preamble -----------------------------------
|
# --- a changelog holding only its preamble -----------------------------------
|
||||||
|
|
||||||
tree preamble-only <<'EOF'
|
tree preamble-only <<'EOF'
|
||||||
|
|
@ -196,45 +151,13 @@ tree preamble-only <<'EOF'
|
||||||
Only preamble so far.
|
Only preamble so far.
|
||||||
EOF
|
EOF
|
||||||
frag preamble-only 1.md <<'EOF'
|
frag preamble-only 1.md <<'EOF'
|
||||||
- The first entry ever (#1).
|
- The first entry ever.
|
||||||
EOF
|
EOF
|
||||||
check "a changelog with no section yet gets the section after the preamble" 0 "" \
|
check "a changelog with no section yet gets the section after the preamble" 0 "" \
|
||||||
in_tree preamble-only 0.1.0 2026-07-24
|
in_tree preamble-only 0.1.0 2026-07-24
|
||||||
check "preamble-only write is exact" 0 "" \
|
check "preamble-only write is exact" 0 "" \
|
||||||
assert_file "$TMP/preamble-only/CHANGELOG.md" \
|
assert_file "$TMP/preamble-only/CHANGELOG.md" \
|
||||||
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).'
|
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.'
|
||||||
|
|
||||||
# --- the fragment predicate at release time (#262) ---------------------------
|
|
||||||
|
|
||||||
# The cite rule joins changelog_fragment_problem, so it binds both callers:
|
|
||||||
# the arming guard at PR time and this assembler at release time. Asserted
|
|
||||||
# rather than assumed — a release that publishes an uncited entry is the
|
|
||||||
# failure the PR-time guard exists to have already caught.
|
|
||||||
|
|
||||||
tree uncited-release <<EOF
|
|
||||||
$BASE_CHANGELOG
|
|
||||||
EOF
|
|
||||||
frag uncited-release 60.md <<'EOF'
|
|
||||||
- An entry that never learned to cite its issue.
|
|
||||||
EOF
|
|
||||||
check "release time: an uncited fragment refuses the release, fragment named" 1 \
|
|
||||||
"changelog.d/60.md' has an entry with no issue citation" \
|
|
||||||
in_tree uncited-release 0.2.0 2026-07-24
|
|
||||||
check "release time: the uncited refusal survives --check too" 1 \
|
|
||||||
"has an entry with no issue citation" \
|
|
||||||
in_tree uncited-release 0.2.0 2026-07-24 --check
|
|
||||||
check "release time: the refused release wrote nothing" 0 "" \
|
|
||||||
test -e "$TMP/uncited-release/changelog.d/60.md"
|
|
||||||
|
|
||||||
tree misplaced-release <<EOF
|
|
||||||
$BASE_CHANGELOG
|
|
||||||
EOF
|
|
||||||
frag misplaced-release 61.md <<'EOF'
|
|
||||||
- The citation trails the period. (#61)
|
|
||||||
EOF
|
|
||||||
check "release time: a non-terminal citation refuses the release" 1 \
|
|
||||||
"changelog.d/61.md' has an entry whose issue citation is not terminal" \
|
|
||||||
in_tree misplaced-release 0.2.0 2026-07-24
|
|
||||||
|
|
||||||
# --- --check is provably read-only -------------------------------------------
|
# --- --check is provably read-only -------------------------------------------
|
||||||
|
|
||||||
|
|
@ -242,10 +165,10 @@ tree check-readonly <<EOF
|
||||||
$BASE_CHANGELOG
|
$BASE_CHANGELOG
|
||||||
EOF
|
EOF
|
||||||
frag check-readonly 5.md <<'EOF'
|
frag check-readonly 5.md <<'EOF'
|
||||||
- Five (#5).
|
- Five.
|
||||||
EOF
|
EOF
|
||||||
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
|
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
|
||||||
check "--check prints the assembled body" 0 "Five (#5)." \
|
check "--check prints the assembled body" 0 "Five." \
|
||||||
in_tree check-readonly 0.2.0 2026-07-24 --check
|
in_tree check-readonly 0.2.0 2026-07-24 --check
|
||||||
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
|
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
|
||||||
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
|
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
|
||||||
|
|
@ -261,11 +184,11 @@ check "the defaulted stamp is a UTC date" 0 "" \
|
||||||
|
|
||||||
mkdir -p "$TMP/flagged/frags"
|
mkdir -p "$TMP/flagged/frags"
|
||||||
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
|
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
|
||||||
printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md"
|
printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md"
|
||||||
check "--changelog and --dir override the defaults" 0 "" \
|
check "--changelog and --dir override the defaults" 0 "" \
|
||||||
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags"
|
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags"
|
||||||
check "the flag-driven write landed in the named changelog" 0 "" \
|
check "the flag-driven write landed in the named changelog" 0 "" \
|
||||||
grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md"
|
grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md"
|
||||||
|
|
||||||
# --- refusals: each names the file responsible -------------------------------
|
# --- refusals: each names the file responsible -------------------------------
|
||||||
|
|
||||||
|
|
@ -324,7 +247,7 @@ tree stray-txt <<EOF
|
||||||
$BASE_CHANGELOG
|
$BASE_CHANGELOG
|
||||||
EOF
|
EOF
|
||||||
frag stray-txt 7.md <<'EOF'
|
frag stray-txt 7.md <<'EOF'
|
||||||
- Seven (#7).
|
- Seven.
|
||||||
EOF
|
EOF
|
||||||
frag stray-txt notes.txt <<'EOF'
|
frag stray-txt notes.txt <<'EOF'
|
||||||
A stray scratchpad.
|
A stray scratchpad.
|
||||||
|
|
@ -354,12 +277,12 @@ tree mixed <<EOF
|
||||||
$BASE_CHANGELOG
|
$BASE_CHANGELOG
|
||||||
EOF
|
EOF
|
||||||
frag mixed 5.md <<'EOF'
|
frag mixed 5.md <<'EOF'
|
||||||
- Flat five (#5).
|
- Flat five.
|
||||||
EOF
|
EOF
|
||||||
frag mixed 6.md <<'EOF'
|
frag mixed 6.md <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Grouped six (#6).
|
- Grouped six.
|
||||||
EOF
|
EOF
|
||||||
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
|
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
|
||||||
in_tree mixed 0.2.0
|
in_tree mixed 0.2.0
|
||||||
|
|
@ -372,7 +295,7 @@ EOF
|
||||||
frag grouped-over-flat 6.md <<'EOF'
|
frag grouped-over-flat 6.md <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Grouped six (#6).
|
- Grouped six.
|
||||||
EOF
|
EOF
|
||||||
check "an all-grouped set over a flat published section refuses before assembly" 1 \
|
check "an all-grouped set over a flat published section refuses before assembly" 1 \
|
||||||
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
|
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
|
||||||
|
|
@ -386,7 +309,7 @@ tree already <<'EOF'
|
||||||
- Already shipped.
|
- Already shipped.
|
||||||
EOF
|
EOF
|
||||||
frag already 4.md <<'EOF'
|
frag already 4.md <<'EOF'
|
||||||
- A late fragment (#4).
|
- A late fragment.
|
||||||
EOF
|
EOF
|
||||||
check "an already-present section refuses — the ceremony was already run" 1 \
|
check "an already-present section refuses — the ceremony was already run" 1 \
|
||||||
"already has a section for '0.2.0'" \
|
"already has a section for '0.2.0'" \
|
||||||
|
|
@ -402,13 +325,13 @@ tree rc-present <<'EOF'
|
||||||
- The candidate's entry.
|
- The candidate's entry.
|
||||||
EOF
|
EOF
|
||||||
frag rc-present 8.md <<'EOF'
|
frag rc-present 8.md <<'EOF'
|
||||||
- The real release entry (#8).
|
- The real release entry.
|
||||||
EOF
|
EOF
|
||||||
check "an rc section does not block assembling the bare version" 0 "" \
|
check "an rc section does not block assembling the bare version" 0 "" \
|
||||||
in_tree rc-present 0.2.0 2026-07-24
|
in_tree rc-present 0.2.0 2026-07-24
|
||||||
|
|
||||||
mkdir -p "$TMP/no-changelog/changelog.d"
|
mkdir -p "$TMP/no-changelog/changelog.d"
|
||||||
printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md"
|
printf -- '- Entry.\n' >"$TMP/no-changelog/changelog.d/2.md"
|
||||||
check "a missing changelog refuses" 1 "no such file" \
|
check "a missing changelog refuses" 1 "no such file" \
|
||||||
in_tree no-changelog 0.2.0
|
in_tree no-changelog 0.2.0
|
||||||
|
|
||||||
|
|
@ -436,12 +359,12 @@ frag round-trip 30.md <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Thirty — wraps onto a
|
- Thirty — wraps onto a
|
||||||
continuation line with a naïve café (#30).
|
continuation line with a naïve café.
|
||||||
EOF
|
EOF
|
||||||
frag round-trip 29.md <<'EOF'
|
frag round-trip 29.md <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed twenty-nine (#29).
|
- Fixed twenty-nine.
|
||||||
EOF
|
EOF
|
||||||
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
|
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
|
||||||
check "round trip: write mode succeeds after --check" 0 "" \
|
check "round trip: write mode succeeds after --check" 0 "" \
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ Preamble prose belongs to no section.
|
||||||
- The shipped entry.
|
- The shipped entry.
|
||||||
EOF
|
EOF
|
||||||
printf '0.1.1-dev\n' >"$dir/VERSION"
|
printf '0.1.1-dev\n' >"$dir/VERSION"
|
||||||
printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md"
|
printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md"
|
||||||
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md"
|
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md"
|
||||||
commit_base "$name"
|
commit_base "$name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
|
||||||
|
|
||||||
seed_flat faithful-grouped
|
seed_flat faithful-grouped
|
||||||
sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md"
|
sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md"
|
||||||
printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md"
|
printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md"
|
||||||
printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\n' >"$TMP/faithful-grouped/changelog.d/20.md"
|
printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md"
|
||||||
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
|
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
|
||||||
git -C "$TMP/faithful-grouped" add -A
|
git -C "$TMP/faithful-grouped" add -A
|
||||||
git -C "$TMP/faithful-grouped" commit -qm regroup
|
git -C "$TMP/faithful-grouped" commit -qm regroup
|
||||||
|
|
@ -108,7 +108,7 @@ check "the stamp's date never enters the comparison" 0 "byte-for-byte" \
|
||||||
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
|
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
|
||||||
|
|
||||||
seed_flat ordinary-add
|
seed_flat ordinary-add
|
||||||
printf -- '- Thirteen incoming (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md"
|
printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md"
|
||||||
commit_head ordinary-add
|
commit_head ordinary-add
|
||||||
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base
|
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base
|
||||||
|
|
||||||
|
|
@ -195,8 +195,8 @@ Preamble prose belongs to no section.
|
||||||
## 0.2.0 — 2026-07-24
|
## 0.2.0 — 2026-07-24
|
||||||
|
|
||||||
- Nine landed, and its prose wraps onto a
|
- Nine landed, and its prose wraps onto a
|
||||||
continuation line (#9).
|
continuation line.
|
||||||
- Twelve landed (#12).
|
- Twelve landed.
|
||||||
|
|
||||||
## 0.1.0 — 2026-07-01
|
## 0.1.0 — 2026-07-01
|
||||||
|
|
||||||
|
|
@ -210,56 +210,13 @@ check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base
|
||||||
# directory is not — only the survivor refusal fires.
|
# directory is not — only the survivor refusal fires.
|
||||||
seed_flat survivor
|
seed_flat survivor
|
||||||
ceremony survivor 0.2.0 2026-07-24
|
ceremony survivor 0.2.0 2026-07-24
|
||||||
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$TMP/survivor/changelog.d/9.md"
|
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md"
|
||||||
commit_head survivor
|
commit_head survivor
|
||||||
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
|
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
|
||||||
run survivor base
|
run survivor base
|
||||||
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
|
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
|
||||||
run survivor base
|
run survivor base
|
||||||
|
|
||||||
# A release PR can be faithful to its merge base while the target branch moves
|
|
||||||
# ahead and gains a fragment during review. That target-only fragment was not
|
|
||||||
# available to the ceremony, so merging the PR would strand it for the next
|
|
||||||
# release. The guard must read the target ref as well as their merge base.
|
|
||||||
seed_flat target-stranded
|
|
||||||
ceremony target-stranded 0.2.0 2026-07-24
|
|
||||||
commit_head target-stranded
|
|
||||||
git -C "$TMP/target-stranded" switch -q base
|
|
||||||
printf -- '- Landed while the release was under review (#30).\n' \
|
|
||||||
>"$TMP/target-stranded/changelog.d/30.md"
|
|
||||||
git -C "$TMP/target-stranded" add -A
|
|
||||||
git -C "$TMP/target-stranded" commit -qm target-fragment
|
|
||||||
git -C "$TMP/target-stranded" switch -q main
|
|
||||||
check "a target-head fragment the release did not consume fails" 1 \
|
|
||||||
"changelog.d/30.md" run target-stranded base
|
|
||||||
check "the target-stranding refusal names the rebase remedy" 1 \
|
|
||||||
"rebase onto the target head" run target-stranded base
|
|
||||||
check "the target-stranding refusal names the assembler re-run" 1 \
|
|
||||||
"changelog-assemble '0.2.0'" run target-stranded base
|
|
||||||
|
|
||||||
# Removing the target-only fragment makes the same diverged fixture green:
|
|
||||||
# target drift itself is not the failure, only a stranded fragment is.
|
|
||||||
git -C "$TMP/target-stranded" switch -q base
|
|
||||||
rm "$TMP/target-stranded/changelog.d/30.md"
|
|
||||||
git -C "$TMP/target-stranded" add -A
|
|
||||||
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
|
|
||||||
git -C "$TMP/target-stranded" switch -q main
|
|
||||||
check "the same target fixture is green once no fragment is stranded" 0 \
|
|
||||||
"byte-for-byte" run target-stranded base
|
|
||||||
|
|
||||||
# Spell out the common harmless case independently: the target branch moved,
|
|
||||||
# but the advancing commit added no fragment.
|
|
||||||
seed_flat target-unrelated
|
|
||||||
ceremony target-unrelated 0.2.0 2026-07-24
|
|
||||||
commit_head target-unrelated
|
|
||||||
git -C "$TMP/target-unrelated" switch -q base
|
|
||||||
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
|
|
||||||
git -C "$TMP/target-unrelated" add -A
|
|
||||||
git -C "$TMP/target-unrelated" commit -qm target-unrelated
|
|
||||||
git -C "$TMP/target-unrelated" switch -q main
|
|
||||||
check "a target head advanced without a fragment stays green" 0 \
|
|
||||||
"byte-for-byte" run target-unrelated base
|
|
||||||
|
|
||||||
# Fragments consumed, section never stamped: the prose went nowhere.
|
# Fragments consumed, section never stamped: the prose went nowhere.
|
||||||
seed_flat halfdone
|
seed_flat halfdone
|
||||||
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
||||||
|
|
@ -361,7 +318,7 @@ init_repo env-tree
|
||||||
mkdir -p "$TMP/env-tree/frags"
|
mkdir -p "$TMP/env-tree/frags"
|
||||||
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md"
|
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md"
|
||||||
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION"
|
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION"
|
||||||
printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md"
|
printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md"
|
||||||
git -C "$TMP/env-tree" add -A
|
git -C "$TMP/env-tree" add -A
|
||||||
git -C "$TMP/env-tree" commit -qm base
|
git -C "$TMP/env-tree" commit -qm base
|
||||||
git -C "$TMP/env-tree" branch fixture-base
|
git -C "$TMP/env-tree" branch fixture-base
|
||||||
|
|
|
||||||
|
|
@ -182,11 +182,11 @@ printf 'marker\n' >"$FRAG/README.md"
|
||||||
check "fragments: README.md is the directory marker, never a fragment" 0 "" \
|
check "fragments: README.md is the directory marker, never a fragment" 0 "" \
|
||||||
changelog_fragments "$FRAG"
|
changelog_fragments "$FRAG"
|
||||||
|
|
||||||
printf -- '- Two (#2).\n' >"$FRAG/2.md"
|
printf -- '- Two.\n' >"$FRAG/2.md"
|
||||||
printf -- '- Nine (#9).\n' >"$FRAG/9.md"
|
printf -- '- Nine.\n' >"$FRAG/9.md"
|
||||||
printf -- '- Ten (#10).\n' >"$FRAG/10.md"
|
printf -- '- Ten.\n' >"$FRAG/10.md"
|
||||||
printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md"
|
printf -- '- Cross.\n' >"$FRAG/ceremony-14.md"
|
||||||
printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md"
|
printf -- '- Local fourteen.\n' >"$FRAG/14.md"
|
||||||
|
|
||||||
assert_fragments_order() {
|
assert_fragments_order() {
|
||||||
local expected="$1" actual
|
local expected="$1" actual
|
||||||
|
|
@ -205,19 +205,19 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b
|
||||||
PF="$TMP/frag-problems"
|
PF="$TMP/frag-problems"
|
||||||
mkdir -p "$PF"
|
mkdir -p "$PF"
|
||||||
|
|
||||||
printf -- '- Fine (#7).\n' >"$PF/7.md"
|
printf -- '- Fine.\n' >"$PF/7.md"
|
||||||
check "fragment predicate: a flat fragment passes" 0 "" \
|
check "fragment predicate: a flat fragment passes" 0 "" \
|
||||||
changelog_fragment_problem "$PF/7.md"
|
changelog_fragment_problem "$PF/7.md"
|
||||||
|
|
||||||
cat >"$PF/8.md" <<'EOF'
|
cat >"$PF/8.md" <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Grouped fine (#8).
|
- Grouped fine.
|
||||||
EOF
|
EOF
|
||||||
check "fragment predicate: a grouped fragment passes" 0 "" \
|
check "fragment predicate: a grouped fragment passes" 0 "" \
|
||||||
changelog_fragment_problem "$PF/8.md"
|
changelog_fragment_problem "$PF/8.md"
|
||||||
|
|
||||||
printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md"
|
printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md"
|
||||||
check "fragment predicate: a cross-repo name passes" 0 "" \
|
check "fragment predicate: a cross-repo name passes" 0 "" \
|
||||||
changelog_fragment_problem "$PF/ceremony-14.md"
|
changelog_fragment_problem "$PF/ceremony-14.md"
|
||||||
|
|
||||||
|
|
@ -259,223 +259,6 @@ check "fragment predicate: a dangling grouped heading is refused, heading named"
|
||||||
"has an empty heading: '### Added'" \
|
"has an empty heading: '### Added'" \
|
||||||
changelog_fragment_problem "$PF/22.md"
|
changelog_fragment_problem "$PF/22.md"
|
||||||
|
|
||||||
# --- the entry length bound (#167) -------------------------------------------
|
|
||||||
|
|
||||||
# mkchars <n> — a run of n 'a's, for entries of exact constructed length.
|
|
||||||
mkchars() {
|
|
||||||
awk -v n="$1" 'BEGIN { s = ""; while (length(s) < n) s = s "a"; print s }'
|
|
||||||
}
|
|
||||||
|
|
||||||
printf -- '- %s\n' "$(mkchars 301)" >"$PF/30.md"
|
|
||||||
check "length bound: a 301-character entry is refused, fragment and length named" 1 \
|
|
||||||
"30.md' has a 301-character entry" \
|
|
||||||
changelog_fragment_problem "$PF/30.md"
|
|
||||||
check "length bound: the refusal names the bound and the split fix" 1 \
|
|
||||||
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
|
|
||||||
changelog_fragment_problem "$PF/30.md"
|
|
||||||
|
|
||||||
printf -- '- %s (#31).\n' "$(mkchars 293)" >"$PF/31.md"
|
|
||||||
check "length bound: an entry of exactly 300 passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/31.md"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf -- '- %s (#32).\n' "$(mkchars 143)"
|
|
||||||
printf -- '- %s (#32).\n' "$(mkchars 143)"
|
|
||||||
printf -- '- %s (#32).\n' "$(mkchars 143)"
|
|
||||||
} >"$PF/32.md"
|
|
||||||
check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/32.md"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf -- '- %s\n' "$(mkchars 50)"
|
|
||||||
printf ' %s\n' "$(mkchars 50)"
|
|
||||||
printf ' %s\n' "$(mkchars 50)"
|
|
||||||
printf ' %s\n' "$(mkchars 50)"
|
|
||||||
printf ' %s (#33).\n' "$(mkchars 50)"
|
|
||||||
} >"$PF/33.md"
|
|
||||||
check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/33.md"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf '### Added\n\n'
|
|
||||||
printf -- '- %s (#34).\n' "$(mkchars 293)"
|
|
||||||
} >"$PF/34.md"
|
|
||||||
check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/34.md"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf '### Added\n\n'
|
|
||||||
printf -- '- %s\n' "$(mkchars 301)"
|
|
||||||
} >"$PF/35.md"
|
|
||||||
check "length bound: a grouped bullet is bounded the same as a flat one" 1 \
|
|
||||||
"35.md' has a 301-character entry" \
|
|
||||||
changelog_fragment_problem "$PF/35.md"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf -- '- %s\n' "$(mkchars 301)"
|
|
||||||
printf -- '- Short.\n'
|
|
||||||
} >"$PF/36.md"
|
|
||||||
assert_single_diagnosis() {
|
|
||||||
local count
|
|
||||||
count="$(changelog_fragment_problem "$PF/36.md" | grep -c "301-character")"
|
|
||||||
[ "$count" = 1 ] || {
|
|
||||||
printf 'wanted one diagnosis, got %s\n' "$count"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check "length bound: an over-bound entry mid-file is diagnosed exactly once" 0 "" \
|
|
||||||
assert_single_diagnosis
|
|
||||||
|
|
||||||
check "length bound: published sections stay unvalidated — 0.3.0's over-bound entries red nothing" 0 "" \
|
|
||||||
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
|
|
||||||
check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \
|
|
||||||
changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0
|
|
||||||
|
|
||||||
# --- the terminal issue cite (#262) ------------------------------------------
|
|
||||||
|
|
||||||
# cite_case <number> <entry-line...> — a fragment holding exactly the given
|
|
||||||
# lines, so a case reads as the entry it is about.
|
|
||||||
cite_case() {
|
|
||||||
local num="$1"
|
|
||||||
shift
|
|
||||||
printf '%s\n' "$@" >"$PF/$num.md"
|
|
||||||
}
|
|
||||||
|
|
||||||
cite_case 40 '- Local (#262).'
|
|
||||||
check "cite: the canonical '(#N).' passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/40.md"
|
|
||||||
cite_case 41 '- Sibling repo (crew#309).'
|
|
||||||
check "cite: a sibling-repo reference passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/41.md"
|
|
||||||
cite_case 42 '- Fully qualified (heavy-duty/crew#309).'
|
|
||||||
check "cite: an owner/repo reference passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/42.md"
|
|
||||||
cite_case 43 '- Two issues, one entry (#236, #250).'
|
|
||||||
check "cite: one group carrying two references passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/43.md"
|
|
||||||
|
|
||||||
# The cite is measured on the normalized entry, so a citation that lands on
|
|
||||||
# a continuation line still closes the entry — the #167 lesson, repeated:
|
|
||||||
# wrapping alone must never red a compliant entry.
|
|
||||||
cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).'
|
|
||||||
check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/44.md"
|
|
||||||
|
|
||||||
cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \
|
|
||||||
'### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).'
|
|
||||||
check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/45.md"
|
|
||||||
|
|
||||||
# The two diagnoses are distinct by construction (D5): a builder who reads
|
|
||||||
# one must not be told the other's fix.
|
|
||||||
cite_case 50 '- No cite here.'
|
|
||||||
check "cite: an entry with no reference at all is refused" 1 \
|
|
||||||
"50.md' has an entry with no issue citation" \
|
|
||||||
changelog_fragment_problem "$PF/50.md"
|
|
||||||
check "cite: the uncited refusal names the shape to write" 1 \
|
|
||||||
"end it with the issue it comes from: '(#N).'" \
|
|
||||||
changelog_fragment_problem "$PF/50.md"
|
|
||||||
|
|
||||||
cite_case 51 '- Cite before the period. (#262)'
|
|
||||||
check "cite: a citation trailing the period is refused — the 248.md shape" 1 \
|
|
||||||
"51.md' has an entry whose issue citation is not terminal" \
|
|
||||||
changelog_fragment_problem "$PF/51.md"
|
|
||||||
check "cite: the misplaced refusal names the shape to write" 1 \
|
|
||||||
"exactly one '(#N)' group ends the entry, the final '.' after it" \
|
|
||||||
changelog_fragment_problem "$PF/51.md"
|
|
||||||
|
|
||||||
cite_case 52 '- Trailing prose (#262) and then more.'
|
|
||||||
check "cite: a citation with prose after it is refused" 1 \
|
|
||||||
"has an entry whose issue citation is not terminal" \
|
|
||||||
changelog_fragment_problem "$PF/52.md"
|
|
||||||
|
|
||||||
cite_case 53 '- Two groups (#236) and (#250).'
|
|
||||||
check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \
|
|
||||||
"has an entry whose issue citation is not terminal" \
|
|
||||||
changelog_fragment_problem "$PF/53.md"
|
|
||||||
|
|
||||||
cite_case 54 '- Bad token (#abc).'
|
|
||||||
check "cite: a reference with no digits is no reference" 1 \
|
|
||||||
"has an entry with no issue citation" \
|
|
||||||
changelog_fragment_problem "$PF/54.md"
|
|
||||||
cite_case 55 '- Bad token (#).'
|
|
||||||
check "cite: an empty reference is no reference" 1 \
|
|
||||||
"has an entry with no issue citation" \
|
|
||||||
changelog_fragment_problem "$PF/55.md"
|
|
||||||
|
|
||||||
# The citation need not name the file's own issue (D3): the filename already
|
|
||||||
# carries the authorizing one, so an entry may cite the incident beside it.
|
|
||||||
cite_case 56 '- Cites another issue entirely (#101).'
|
|
||||||
check "cite: the reference need not match the filename" 0 "" \
|
|
||||||
changelog_fragment_problem "$PF/56.md"
|
|
||||||
|
|
||||||
# Ordering: the bound outranks the cite across the whole fragment, so a
|
|
||||||
# fragment that reds today draws the diagnosis it drew before this rule
|
|
||||||
# existed. The uncited entry comes FIRST here on purpose — the other order
|
|
||||||
# would pass whatever the precedence is.
|
|
||||||
{
|
|
||||||
printf -- '- Uncited, and it comes first.\n'
|
|
||||||
printf -- '- %s\n' "$(mkchars 301)"
|
|
||||||
} >"$PF/57.md"
|
|
||||||
check "cite: an over-bound entry outranks an earlier uncited one" 1 \
|
|
||||||
"57.md' has a 301-character entry" \
|
|
||||||
changelog_fragment_problem "$PF/57.md"
|
|
||||||
assert_one_diagnosis() {
|
|
||||||
local count
|
|
||||||
count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)"
|
|
||||||
[ "$count" = 1 ] || {
|
|
||||||
printf 'wanted one diagnosis, got %s\n' "$count"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check "cite: the outranked citation problem is not reported beside it" 0 "" \
|
|
||||||
assert_one_diagnosis 57
|
|
||||||
|
|
||||||
# The axis 57.md cannot test: its over-bound entry is LAST, so the only
|
|
||||||
# flush that can print is END's, which exits immediately. A flush from a
|
|
||||||
# main rule exits too — but awk runs END on the way out, so the citation
|
|
||||||
# row a mid-file length row outranks would print after it unless END is
|
|
||||||
# guarded. Both ways out of the walk, a bullet and a heading.
|
|
||||||
{
|
|
||||||
printf -- '- Uncited, and it comes first.\n'
|
|
||||||
printf -- '- %s\n' "$(mkchars 301)"
|
|
||||||
printf -- '- A later entry the walk never reaches (#57).\n'
|
|
||||||
} >"$PF/58.md"
|
|
||||||
check "cite: an over-bound entry that is not the last one still reports the bound" 1 \
|
|
||||||
"58.md' has a 301-character entry" \
|
|
||||||
changelog_fragment_problem "$PF/58.md"
|
|
||||||
check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \
|
|
||||||
assert_one_diagnosis 58
|
|
||||||
{
|
|
||||||
printf '### Fixed\n'
|
|
||||||
printf -- '- Misplaced, and it comes first. (#59)\n'
|
|
||||||
printf -- '- %s\n' "$(mkchars 301)"
|
|
||||||
printf '### Changed\n'
|
|
||||||
printf -- '- The heading is the other way out of the walk (#59).\n'
|
|
||||||
} >"$PF/59.md"
|
|
||||||
check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \
|
|
||||||
"59.md' has a 301-character entry" \
|
|
||||||
changelog_fragment_problem "$PF/59.md"
|
|
||||||
check "cite: the misplaced row does not ride along with it either" 0 "" \
|
|
||||||
assert_one_diagnosis 59
|
|
||||||
|
|
||||||
# Published sections keep their pre-rule prose (D4): reddening history is a
|
|
||||||
# wall, not a guard. Every shipped section predates the cite.
|
|
||||||
check "cite: a published section with uncited entries still reds nothing" 0 "" \
|
|
||||||
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
|
|
||||||
|
|
||||||
# The fragments this repo carries right now are the rule's own first
|
|
||||||
# constituency — the guard is worth nothing if the tree it ships in fails it.
|
|
||||||
assert_tree_fragments() {
|
|
||||||
local f
|
|
||||||
while IFS= read -r f; do
|
|
||||||
[ -n "$f" ] || continue
|
|
||||||
changelog_fragment_problem "$f" || return 1
|
|
||||||
done <<<"$(changelog_fragments "$ROOT/changelog.d")"
|
|
||||||
}
|
|
||||||
check "cite: every fragment in this tree passes the rule it ships" 0 "" \
|
|
||||||
assert_tree_fragments
|
|
||||||
|
|
||||||
# --- the assembler (#114) ----------------------------------------------------
|
# --- the assembler (#114) ----------------------------------------------------
|
||||||
|
|
||||||
assert_assemble() {
|
assert_assemble() {
|
||||||
|
|
@ -492,11 +275,11 @@ mkdir -p "$AF"
|
||||||
printf 'marker\n' >"$AF/README.md"
|
printf 'marker\n' >"$AF/README.md"
|
||||||
cat >"$AF/3.md" <<'EOF'
|
cat >"$AF/3.md" <<'EOF'
|
||||||
- Three — an em dash, and prose that
|
- Three — an em dash, and prose that
|
||||||
wraps onto a continuation line (#3).
|
wraps onto a continuation line.
|
||||||
EOF
|
EOF
|
||||||
printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md"
|
printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md"
|
||||||
check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \
|
check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \
|
||||||
assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).'
|
assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.'
|
||||||
|
|
||||||
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
|
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
|
||||||
changelog_assemble "$TMP/no-such-dir"
|
changelog_assemble "$TMP/no-such-dir"
|
||||||
|
|
@ -506,36 +289,36 @@ mkdir -p "$AG"
|
||||||
cat >"$AG/21.md" <<'EOF'
|
cat >"$AG/21.md" <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed twenty-one (#21).
|
- Fixed twenty-one.
|
||||||
EOF
|
EOF
|
||||||
cat >"$AG/20.md" <<'EOF'
|
cat >"$AG/20.md" <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added twenty (#20).
|
- Added twenty.
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
- Docs twenty (#20).
|
- Docs twenty.
|
||||||
EOF
|
EOF
|
||||||
cat >"$AG/19.md" <<'EOF'
|
cat >"$AG/19.md" <<'EOF'
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Security nineteen (#19).
|
- Security nineteen.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added nineteen (#19).
|
- Added nineteen.
|
||||||
EOF
|
EOF
|
||||||
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \
|
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \
|
||||||
assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
|
assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
|
||||||
|
|
||||||
AM="$TMP/assemble-mixed"
|
AM="$TMP/assemble-mixed"
|
||||||
mkdir -p "$AM"
|
mkdir -p "$AM"
|
||||||
printf -- '- Flat five (#5).\n' >"$AM/5.md"
|
printf -- '- Flat five.\n' >"$AM/5.md"
|
||||||
cat >"$AM/6.md" <<'EOF'
|
cat >"$AM/6.md" <<'EOF'
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Grouped six (#6).
|
- Grouped six.
|
||||||
EOF
|
EOF
|
||||||
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
|
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
|
||||||
changelog_assemble "$AM"
|
changelog_assemble "$AM"
|
||||||
|
|
@ -545,11 +328,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \
|
||||||
AX="$TMP/assemble-selfmixed"
|
AX="$TMP/assemble-selfmixed"
|
||||||
mkdir -p "$AX"
|
mkdir -p "$AX"
|
||||||
cat >"$AX/7.md" <<'EOF'
|
cat >"$AX/7.md" <<'EOF'
|
||||||
- Ungrouped lead (#7).
|
- Ungrouped lead.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Grouped follow (#7).
|
- Grouped follow.
|
||||||
EOF
|
EOF
|
||||||
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
|
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
|
||||||
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
|
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
|
||||||
|
|
@ -574,14 +357,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
|
||||||
|
|
||||||
- Older section is grouped.
|
- Older section is grouped.
|
||||||
EOF
|
EOF
|
||||||
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
|
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
|
||||||
check "shape: flat set matches newest flat published section" 0 "" \
|
check "shape: flat set matches newest flat published section" 0 "" \
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||||
|
|
||||||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Grouped fragment (#1).
|
- Grouped fragment.
|
||||||
EOF
|
EOF
|
||||||
check "shape: grouped set names its conflict with newest flat published section" 1 \
|
check "shape: grouped set names its conflict with newest flat published section" 1 \
|
||||||
"fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
|
"fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
|
||||||
|
|
@ -596,7 +379,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
|
||||||
|
|
||||||
- Newest section is grouped.
|
- Newest section is grouped.
|
||||||
EOF
|
EOF
|
||||||
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
|
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
|
||||||
check "shape: flat set names its conflict with newest grouped published section" 1 \
|
check "shape: flat set names its conflict with newest grouped published section" 1 \
|
||||||
"fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \
|
"fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||||
|
|
@ -604,7 +387,7 @@ check "shape: flat set names its conflict with newest grouped published section"
|
||||||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Grouped fragment (#1).
|
- Grouped fragment.
|
||||||
EOF
|
EOF
|
||||||
check "shape: grouped set matches newest grouped published section" 0 "" \
|
check "shape: grouped set matches newest grouped published section" 0 "" \
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||||
|
|
@ -614,129 +397,4 @@ rm "$SHAPE_DIR/1.md"
|
||||||
check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \
|
check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||||
|
|
||||||
# --- the declarable anchor: <dir>/shape (#182) -------------------------------
|
|
||||||
# The sentinel pins the set's shape and outranks the newest-published-section
|
|
||||||
# inference — the door a deliberate flip walks through, while the undeclared
|
|
||||||
# drift rows above stay red, verbatim.
|
|
||||||
|
|
||||||
cat >"$SHAPE_CHANGELOG" <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 2.0.0 — 2026-07-24
|
|
||||||
|
|
||||||
- Newest section is flat.
|
|
||||||
EOF
|
|
||||||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Grouped fragment (#1).
|
|
||||||
EOF
|
|
||||||
printf 'grouped\n' >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \
|
|
||||||
changelog_shape_problem "" "$SHAPE_DIR"
|
|
||||||
|
|
||||||
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
|
|
||||||
check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \
|
|
||||||
"fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
|
|
||||||
cat >"$SHAPE_CHANGELOG" <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 2.0.0 — 2026-07-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Newest section is grouped.
|
|
||||||
EOF
|
|
||||||
printf 'flat\n' >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: 'flat' sentinel admits a flat set over a grouped published section" 0 "" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Grouped fragment (#1).
|
|
||||||
EOF
|
|
||||||
check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \
|
|
||||||
"fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
|
|
||||||
printf 'grouped\n' >"$SHAPE_DIR/shape"
|
|
||||||
printf -- '- Flat two (#2).\n' >"$SHAPE_DIR/2.md"
|
|
||||||
check "shape: a mixed set is refused regardless of the sentinel" 1 \
|
|
||||||
"fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/2.md"
|
|
||||||
|
|
||||||
check "shape: empty fragment set with a valid sentinel passes" 0 "" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
|
|
||||||
printf 'Grouped\n' >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: a capitalized sentinel is refused, file named" 1 \
|
|
||||||
"'$SHAPE_DIR/shape' declares neither shape" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
: >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: an empty sentinel is refused — never a silent fallback" 1 \
|
|
||||||
"'$SHAPE_DIR/shape' declares neither shape" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
printf 'grouped\nflat\n' >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: a two-line sentinel is refused" 1 \
|
|
||||||
"'$SHAPE_DIR/shape' declares neither shape" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
# Trailing blank lines are the case command substitution launders away: the
|
|
||||||
# captured word is a clean 'grouped', only the file's line count still knows.
|
|
||||||
printf 'grouped\n\n' >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: 'grouped' with a trailing blank line is refused, file named" 1 \
|
|
||||||
"'$SHAPE_DIR/shape' declares neither shape" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
printf 'flat\n\n' >"$SHAPE_DIR/shape"
|
|
||||||
check "shape: 'flat' with a trailing blank line is refused, file named" 1 \
|
|
||||||
"'$SHAPE_DIR/shape' declares neither shape" \
|
|
||||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
|
||||||
|
|
||||||
# The sentinel is not a fragment (#182 D3): the *.md glob is the mechanism,
|
|
||||||
# but the assertion is on the list itself, so a glob change cannot silently
|
|
||||||
# start publishing the sentinel.
|
|
||||||
printf 'grouped\n' >"$SHAPE_DIR/shape"
|
|
||||||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Grouped fragment (#1).
|
|
||||||
EOF
|
|
||||||
assert_fragments_exclude_sentinel() {
|
|
||||||
local out
|
|
||||||
out="$(changelog_fragments "$SHAPE_DIR")"
|
|
||||||
[ -n "$out" ] || { echo "wanted a non-empty fragment list"; return 1; }
|
|
||||||
if printf '%s\n' "$out" | grep -q '/shape$'; then
|
|
||||||
printf 'the sentinel leaked into the fragment list:\n%s\n' "$out"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
check "fragments: the shape sentinel never enters the fragment list" 0 "" \
|
|
||||||
assert_fragments_exclude_sentinel
|
|
||||||
rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/shape"
|
|
||||||
|
|
||||||
AS="$TMP/assemble-sentinel"
|
|
||||||
mkdir -p "$AS"
|
|
||||||
printf 'grouped\n' >"$AS/shape"
|
|
||||||
cat >"$AS/30.md" <<'EOF'
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed thirty (#30).
|
|
||||||
EOF
|
|
||||||
cat >"$AS/31.md" <<'EOF'
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Added thirty-one (#31).
|
|
||||||
EOF
|
|
||||||
check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \
|
|
||||||
assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).'
|
|
||||||
rm "$AS/30.md" "$AS/31.md"
|
|
||||||
printf -- '- Flat probe (#29).\n' >"$AS/29.md"
|
|
||||||
check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
|
|
||||||
"declares grouped" \
|
|
||||||
changelog_assemble "$AS"
|
|
||||||
|
|
||||||
summary
|
summary
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for lib/closes_references.sh (issue #188, term 3).
|
|
||||||
# set -u, not -e: failing commands 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"
|
|
||||||
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
|
|
||||||
# calls it, exactly as refs_references does.
|
|
||||||
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
|
||||||
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
|
||||||
# shellcheck source=lib/closes_references.sh
|
|
||||||
. "$ROOT/lib/closes_references.sh"
|
|
||||||
|
|
||||||
# closes <want-newline-separated> <body> — the parse of <body> is exactly
|
|
||||||
# <want>. Exact, not substring: "12" is contained in "123".
|
|
||||||
closes() {
|
|
||||||
local want="$1" body="$2" got
|
|
||||||
got="$(printf '%s' "$body" | closes_references)"
|
|
||||||
[ "$got" = "$want" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the three verbs, the three tenses ----------------------------------
|
|
||||||
# GitHub's documented keyword set. All of them, because a body that says
|
|
||||||
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
|
|
||||||
# transition, not a loud one.
|
|
||||||
|
|
||||||
check "closes" 0 "" closes 1 'Closes #1'
|
|
||||||
check "close" 0 "" closes 1 'Close #1'
|
|
||||||
check "closed" 0 "" closes 1 'Closed #1'
|
|
||||||
check "fixes" 0 "" closes 2 'Fixes #2'
|
|
||||||
check "fix" 0 "" closes 2 'Fix #2'
|
|
||||||
check "fixed" 0 "" closes 2 'Fixed #2'
|
|
||||||
check "resolves" 0 "" closes 3 'Resolves #3'
|
|
||||||
check "resolve" 0 "" closes 3 'Resolve #3'
|
|
||||||
check "resolved" 0 "" closes 3 'Resolved #3'
|
|
||||||
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
|
|
||||||
check "lowercase" 0 "" closes 4 'closes #4'
|
|
||||||
check "colon form" 0 "" closes 5 'Closes: #5'
|
|
||||||
|
|
||||||
# --- Refs is NOT a closing link -----------------------------------------
|
|
||||||
# The relation this file must not swallow. refs_references owns Refs, and
|
|
||||||
# conflating them makes every referenced issue look closeable — the
|
|
||||||
# post-merge transition #151 was reopened by hand over exactly that
|
|
||||||
# distinction.
|
|
||||||
|
|
||||||
check "Refs is not a close" 0 "" closes '' 'Refs #7'
|
|
||||||
check "Refs and Closes in one body keeps only the close" 0 "" \
|
|
||||||
closes 8 $'Refs #7\nCloses #8'
|
|
||||||
|
|
||||||
# --- cross-repo references stay out (#61) -------------------------------
|
|
||||||
# rig#112 must never be read as local #112. The classifier is shared with
|
|
||||||
# refs_references precisely so this rule has one implementation.
|
|
||||||
|
|
||||||
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
|
|
||||||
check "owner-qualified reference is not local" 0 "" \
|
|
||||||
closes '' 'Closes heavy-duty/rig#112'
|
|
||||||
check "a local and a cross reference keep only the local" 0 "" \
|
|
||||||
closes 9 $'Closes rig#112\nCloses #9'
|
|
||||||
|
|
||||||
# --- every occurrence contributes ---------------------------------------
|
|
||||||
# Binding to the first occurrence is the defect #184 fixed in
|
|
||||||
# blocked_reference_records; this parser must not reintroduce it.
|
|
||||||
|
|
||||||
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
|
|
||||||
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
|
|
||||||
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
|
|
||||||
|
|
||||||
# --- prose must not be swallowed ----------------------------------------
|
|
||||||
|
|
||||||
check "trailing prose is not part of the reference" 0 "" \
|
|
||||||
closes 12 'Closes #12, and adds the guard'
|
|
||||||
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
|
|
||||||
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
|
|
||||||
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
|
|
||||||
# "unclosed" contains "close" — a naive word match would fire on it.
|
|
||||||
check "a word merely containing a verb does not fire" 0 "" \
|
|
||||||
closes '' 'This left #15 unclosed'
|
|
||||||
|
|
||||||
# --- the shapes a real PR body carries ----------------------------------
|
|
||||||
|
|
||||||
check "the template's leading declaration" 0 "" \
|
|
||||||
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
|
|
||||||
check "an empty body yields nothing" 0 "" closes '' ''
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees
|
# Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees
|
||||||
# (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml
|
# (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml
|
||||||
# caller with the pin line), driven offline via --source. The fetch path is
|
# caller with the pin line), driven offline via --source — the fetch path
|
||||||
# driven too, against a PATH-stubbed curl rather than the network (#201) —
|
# needs the network and is exercised by consumers, not here. The fake
|
||||||
# which forge a pin resolves against is a decision, not plumbing. The fake
|
|
||||||
# source's doc set is deliberately NOT the real five: a script that
|
# source's doc set is deliberately NOT the real five: a script that
|
||||||
# hardcodes the vendored list instead of reading the manifest fails these
|
# hardcodes the vendored list instead of reading the manifest fails these
|
||||||
# rows. set -u, not -e: failing commands are behavior for the harness to
|
# rows. set -u, not -e: failing commands are behavior for the harness to
|
||||||
|
|
@ -19,14 +18,6 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh"
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
# The real manifest is asserted by test/vendored.test.sh, not here (#251 D4).
|
|
||||||
# A `grep -Fx RELEASES.md` row lived at this spot from #248's review round,
|
|
||||||
# binding the promise to the one file that had nearly been missed. It was the
|
|
||||||
# hardcoded list the manifest exists to abolish, one layer down: two spellings
|
|
||||||
# of "the manifest is right" is exactly the drift it prevents. Its intent —
|
|
||||||
# every root doctrine file is declared, RELEASES.md included — is now a
|
|
||||||
# closed-world guard case, which the next file inherits for free.
|
|
||||||
|
|
||||||
# --- fixture builders --------------------------------------------------------
|
# --- fixture builders --------------------------------------------------------
|
||||||
|
|
||||||
# The main fake ceremony tree: three manifest entries, one in a subdirectory
|
# The main fake ceremony tree: three manifest entries, one in a subdirectory
|
||||||
|
|
@ -300,80 +291,4 @@ check "unknown flag refused" 1 "unknown argument" \
|
||||||
check "--source without a directory refused" 1 "no such directory" \
|
check "--source without a directory refused" 1 "no such directory" \
|
||||||
in_consumer env-wired --check --source "$TMP/does-not-exist"
|
in_consumer env-wired --check --source "$TMP/does-not-exist"
|
||||||
|
|
||||||
# --- the fetch path: which forge, and never a guessed one (#201) ---------------
|
|
||||||
|
|
||||||
# The fetch path had no coverage at all before this: every row above passes
|
|
||||||
# --source, which overrides the fetch entirely, so the URL the tool actually
|
|
||||||
# builds was asserted nowhere. It is asserted here with a PATH-stubbed curl
|
|
||||||
# that records the URL and serves a tarball of the fake source tree — no
|
|
||||||
# network, and the real tar pipeline still runs, so --strip-components stays
|
|
||||||
# honest. CURL_FAIL makes the stub fail the way a missing ref does.
|
|
||||||
FETCHBIN="$TMP/fetchbin"
|
|
||||||
mkdir -p "$FETCHBIN"
|
|
||||||
cat >"$FETCHBIN/curl" <<'STUB'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' "${!#}" >>"$CURL_URL_LOG"
|
|
||||||
[ -z "${CURL_FAIL:-}" ] || exit 22
|
|
||||||
exec tar -cz -C "$(dirname "$CURL_SRC")" "$(basename "$CURL_SRC")"
|
|
||||||
STUB
|
|
||||||
chmod +x "$FETCHBIN/curl"
|
|
||||||
|
|
||||||
export CURL_URL_LOG="$TMP/curl-urls" CURL_SRC="$SRC"
|
|
||||||
|
|
||||||
consumer fetched 0.4.1
|
|
||||||
|
|
||||||
# fetch_sync <server-url> <args...> — the fetch path, no --source. Truncates
|
|
||||||
# the URL log first so requested_url always answers about this run.
|
|
||||||
fetch_sync() {
|
|
||||||
local server="$1"
|
|
||||||
shift
|
|
||||||
: >"$CURL_URL_LOG"
|
|
||||||
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" GITHUB_SERVER_URL="$server" \
|
|
||||||
bash "$SCRIPT" "$@")
|
|
||||||
}
|
|
||||||
requested_url() { cat "$CURL_URL_LOG"; }
|
|
||||||
|
|
||||||
check "the fetch mirrors the pin fetched from the forge in the environment" 0 \
|
|
||||||
"added .ceremony/RULES.md" fetch_sync https://forgejo.example.test --fix
|
|
||||||
check "...and the URL asked for names that forge, not a built-in one" 0 \
|
|
||||||
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
|
|
||||||
requested_url
|
|
||||||
|
|
||||||
# One pin ref, two forges, two trees — the whole reason #201 exists. The same
|
|
||||||
# consumer must fetch from whichever forge it is running on.
|
|
||||||
fetch_sync https://github.com --fix >/dev/null 2>&1
|
|
||||||
check "the same pin on another forge fetches from that forge instead" 0 \
|
|
||||||
"https://github.com/heavy-duty/ceremony/archive/0.4.1.tar.gz" requested_url
|
|
||||||
|
|
||||||
fetch_sync https://forgejo.example.test/ --fix >/dev/null 2>&1
|
|
||||||
check "a trailing slash on the server URL does not double the separator" 0 \
|
|
||||||
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
|
|
||||||
requested_url
|
|
||||||
|
|
||||||
# Unset is not github.com. A tool that never guesses a ref must not guess a
|
|
||||||
# forge either — and it must refuse BEFORE reaching for the network.
|
|
||||||
no_server_sync() {
|
|
||||||
: >"$CURL_URL_LOG"
|
|
||||||
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" \
|
|
||||||
env -u GITHUB_SERVER_URL bash "$SCRIPT" --check)
|
|
||||||
}
|
|
||||||
check "no GITHUB_SERVER_URL and no --source → refuse, naming the variable" 1 \
|
|
||||||
"GITHUB_SERVER_URL is unset" no_server_sync
|
|
||||||
check "...and the refusal says it never guesses a forge" 1 \
|
|
||||||
"never guesses a forge" no_server_sync
|
|
||||||
nothing_fetched() { [ ! -s "$CURL_URL_LOG" ]; }
|
|
||||||
check "...and nothing was fetched before refusing" 0 "" nothing_fetched
|
|
||||||
|
|
||||||
# A ref that does not resolve on the forge in play: the message must name the
|
|
||||||
# URL actually attempted, so "does the pinned ref exist" is answerable.
|
|
||||||
fetch_fail() {
|
|
||||||
: >"$CURL_URL_LOG"
|
|
||||||
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" CURL_FAIL=1 \
|
|
||||||
GITHUB_SERVER_URL=https://forgejo.example.test bash "$SCRIPT" --check)
|
|
||||||
}
|
|
||||||
check "a failed fetch names the URL it tried" 1 \
|
|
||||||
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" fetch_fail
|
|
||||||
check "...and asks about the ref on that forge, not in the abstract" 1 \
|
|
||||||
"exist on that forge" fetch_fail
|
|
||||||
|
|
||||||
summary
|
summary
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
. "$ROOT/test/harness.sh"
|
. "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are simply undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
|
|
||||||
FACTS="$ROOT/lib/facts.sh"
|
FACTS="$ROOT/lib/facts.sh"
|
||||||
|
|
||||||
|
|
@ -29,39 +23,20 @@ ZEROS="0000000000000000000000000000000000000000"
|
||||||
mkdir -p "$TMP/stub"
|
mkdir -p "$TMP/stub"
|
||||||
cat >"$TMP/stub/gh" <<'EOF'
|
cat >"$TMP/stub/gh" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Every mode below answers the call shape the shim now makes (#191):
|
|
||||||
# labeled -> gh api repos/{r}/commits/{sha}/pulls (a JSON ARRAY)
|
|
||||||
# released -> gh api repos/{r}/releases/tags/{tag}
|
|
||||||
# The *-unreadable modes are the ones that matter: they fail the way a real
|
|
||||||
# client fails when it cannot reach the forge, and must NOT be reported as a
|
|
||||||
# definite answer.
|
|
||||||
case "${GH_STUB:-none}" in
|
case "${GH_STUB:-none}" in
|
||||||
labeled-yes)
|
labeled-yes | labeled-no)
|
||||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
if [ "$1" != api ]; then
|
||||||
|
echo "gh stub: expected an api call, got: gh $*" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
[ "${GH_STUB}" = labeled-yes ] && echo true || echo false
|
||||||
;;
|
;;
|
||||||
labeled-no)
|
released-yes | released-no)
|
||||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"enhancement"}]}]'
|
if [ "$1" != release ]; then
|
||||||
;;
|
echo "gh stub: expected a release call, got: gh $*" >&2
|
||||||
labeled-none)
|
exit 97
|
||||||
# A completed read that found no PR at all — still an answer.
|
fi
|
||||||
echo '[]'
|
[ "${GH_STUB}" = released-yes ] && exit 0 || exit 1
|
||||||
;;
|
|
||||||
labeled-unmerged)
|
|
||||||
# A PR carrying the label but never merged: the label alone is not a
|
|
||||||
# ceremony (the `merged_at != null` half of the contract).
|
|
||||||
echo '[{"merged_at":null,"labels":[{"name":"release"}]}]'
|
|
||||||
;;
|
|
||||||
labeled-unreadable | released-unreadable)
|
|
||||||
echo "gh: Connection refused (HTTP 000)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
released-yes)
|
|
||||||
echo "$2" | grep -q 'releases/tags/' || { echo "gh stub: expected a releases/tags read, got: gh $*" >&2; exit 97; }
|
|
||||||
echo "0.0.0"
|
|
||||||
;;
|
|
||||||
released-no)
|
|
||||||
echo "gh: Not Found (HTTP 404)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "gh stub: gh must not be called in this state (gh $*)" >&2
|
echo "gh stub: gh must not be called in this state (gh $*)" >&2
|
||||||
|
|
@ -93,7 +68,6 @@ facts_in() {
|
||||||
shift
|
shift
|
||||||
(cd "$TMP/$dir" \
|
(cd "$TMP/$dir" \
|
||||||
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
|
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
|
||||||
CEREMONY_FORGE=github \
|
|
||||||
"$@" bash "$FACTS")
|
"$@" bash "$FACTS")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,37 +208,4 @@ nv_head="$(commit no-version README.md "with no version at the head either")"
|
||||||
check "no version at the head fails loudly" 1 "no such file" \
|
check "no version at the head fails loudly" 1 "no such file" \
|
||||||
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
|
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
|
||||||
|
|
||||||
# --- #191: a read that did not complete is not an answer ------------------
|
|
||||||
# The bug this suite missed before: lib/facts.sh turned ANY failure of the
|
|
||||||
# label read into `labeled=no`, and decide's row 5 then refused a correctly
|
|
||||||
# labeled, correctly merged ceremony PR as "a bare push". On a Forgejo
|
|
||||||
# runner — no `gh` on the image — that was every release. Measured twice in
|
|
||||||
# the 0.4.1 drill (drills/0.4.1.md) before it was fixed.
|
|
||||||
#
|
|
||||||
# The contract now: a COMPLETED read that finds nothing is still `no` and
|
|
||||||
# still fail-closed; a read that could not complete refuses, loudly, and
|
|
||||||
# emits no fact at all.
|
|
||||||
|
|
||||||
check "a completed read with no PR behind the commit is labeled=no" 0 "labeled=no" \
|
|
||||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-none
|
|
||||||
check "a labeled but UNMERGED PR is labeled=no" 0 "labeled=no" \
|
|
||||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unmerged
|
|
||||||
|
|
||||||
check "an unreadable label read refuses instead of saying no" 1 "refusing rather than reporting 'no label'" \
|
|
||||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
|
|
||||||
# ...and emits no fact: a refusal that still printed labeled=no would be the
|
|
||||||
# same bug wearing a diagnostic.
|
|
||||||
check "the refusal emits no labeled fact at all" 1 "" \
|
|
||||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
|
|
||||||
if facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable 2>/dev/null | grep -q '^labeled='; then
|
|
||||||
echo "FAIL: the refusal printed a labeled= line" >&2
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
else
|
|
||||||
echo "ok: no labeled= line survives the refusal"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
check "an unreadable release read refuses instead of saying no" 1 "refusing rather than reporting 'no'" \
|
|
||||||
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-unreadable
|
|
||||||
|
|
||||||
summary
|
summary
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,175 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing
|
|
||||||
# commands are behavior for the harness to inspect.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
# A PATH with the shell and the text tools lib/forge.sh itself uses, but with
|
|
||||||
# NO forge clients on it — that is the condition under test. It cannot be a
|
|
||||||
# genuinely empty directory: `env -i PATH=…` would then fail to find `bash`,
|
|
||||||
# and the heredoc refusals use `cat`.
|
|
||||||
mkdir -p "$TMP/empty"
|
|
||||||
for _t in bash cat sed awk tr printf; do
|
|
||||||
_p="$(command -v "$_t" 2>/dev/null)" && ln -sf "$_p" "$TMP/empty/$_t"
|
|
||||||
done
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
|
|
||||||
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
|
|
||||||
# substring match cannot prove "forgejo" was not printed as "forgejox".
|
|
||||||
eq() {
|
|
||||||
local want="$1" got
|
|
||||||
shift
|
|
||||||
got="$("$@")" || return 1
|
|
||||||
[ "$got" = "$want" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# detect_in <env-assignments…> — run forge_detect in a clean environment
|
|
||||||
# carrying only the named vars, so a leaked GITHUB_* from the CI running
|
|
||||||
# THIS suite cannot decide the answer. Every case below is hermetic.
|
|
||||||
detect_in() {
|
|
||||||
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
|
|
||||||
}
|
|
||||||
|
|
||||||
# A PATH carrying stub binaries for every client the preflight can require.
|
|
||||||
# Without this the "passes" cases depend on whatever the HOST happens to have,
|
|
||||||
# which is not hermetic and is wrong in the only place it matters: the Forgejo
|
|
||||||
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) has **no gh**, so
|
|
||||||
# "github + gh passes" failed there while passing on a developer box. Measured
|
|
||||||
# 2026-08-02 (#188) — the same class of hosted-image assumption this issue
|
|
||||||
# exists to find.
|
|
||||||
STUBBIN="$TMP/bin"
|
|
||||||
mkdir -p "$STUBBIN"
|
|
||||||
for _b in gh curl jq; do printf '#!/bin/sh\nexit 0\n' >"$STUBBIN/$_b"; chmod +x "$STUBBIN/$_b"; done
|
|
||||||
|
|
||||||
preflight_in() {
|
|
||||||
env -i PATH="$STUBBIN:$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ...and one with NO clients at all, for the missing-binary refusal.
|
|
||||||
preflight_bare() {
|
|
||||||
env -i PATH="$TMP/empty" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- forge_detect: the explicit override --------------------------------
|
|
||||||
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
|
|
||||||
# whose env this file has not met yet, and the handle the tests below use
|
|
||||||
# to drive the backends without a live instance.
|
|
||||||
|
|
||||||
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
|
|
||||||
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
|
|
||||||
check "override refuses an unknown forge" 1 "unknown forge" \
|
|
||||||
detect_in CEREMONY_FORGE=gitlab
|
|
||||||
# A typo must not silently fall through to a probe that guesses right by
|
|
||||||
# accident: the operator said something, and it was wrong.
|
|
||||||
check "override outranks the env" 1 "unknown forge" \
|
|
||||||
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
|
|
||||||
|
|
||||||
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
|
|
||||||
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
|
|
||||||
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
|
|
||||||
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
|
|
||||||
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
|
|
||||||
# those URLs point:
|
|
||||||
#
|
|
||||||
# GitHub GITHUB_API_URL=https://api.github.com
|
|
||||||
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
|
|
||||||
#
|
|
||||||
# That is the whole bug this issue exists for, in one variable: gh speaks
|
|
||||||
# /api/v3 against api.github.com, and neither half is true here.
|
|
||||||
|
|
||||||
check "api url: api.github.com is github" 0 "" \
|
|
||||||
eq github detect_in GITHUB_API_URL=https://api.github.com
|
|
||||||
check "api url: /api/v1 is forgejo" 0 "" \
|
|
||||||
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
|
|
||||||
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
|
|
||||||
# it is a github backend on a non-github.com host. Getting this wrong would
|
|
||||||
# route a GHES consumer to the forgejo backend and break term 5.
|
|
||||||
check "api url: GHES /api/v3 is github" 0 "" \
|
|
||||||
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
|
|
||||||
|
|
||||||
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
|
|
||||||
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
|
|
||||||
# which GitHub never sets. It is checked BEFORE the URL shape because it is
|
|
||||||
# unambiguous where a hand-set GITHUB_API_URL might not be.
|
|
||||||
|
|
||||||
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
|
|
||||||
check "gitea marker outranks a github-shaped api url" 0 "" \
|
|
||||||
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
|
|
||||||
|
|
||||||
# --- forge_detect: refusing to guess ------------------------------------
|
|
||||||
# Nothing to read is NOT "probably github". A wrong guess here is exactly
|
|
||||||
# the silent blind sweep #188 measured; the whole point of this file is
|
|
||||||
# that an unknown forge is loud.
|
|
||||||
|
|
||||||
check "bare environment refuses" 1 "cannot determine which forge" detect_in
|
|
||||||
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
|
|
||||||
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
|
|
||||||
|
|
||||||
# --- forge_preflight: the must-fail case --------------------------------
|
|
||||||
# The Test plan's named must-fail: "point it at a Forgejo instance with a
|
|
||||||
# GitHub-shaped client and assert it refuses loudly rather than sweeping
|
|
||||||
# blind."
|
|
||||||
#
|
|
||||||
# Measured before this guard existed, against this instance:
|
|
||||||
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
|
|
||||||
# labels-reconcile exit 0 "reconciled." (zero PRs read)
|
|
||||||
# issueflow-reconcile exit 1 "unexpected end of JSON input"
|
|
||||||
# Two of three swept blind and reported success. gh present made it WORSE:
|
|
||||||
# it silenced the one loud failure. Hence: refuse before the sweep, not
|
|
||||||
# after — and say which forge and which client, so the log answers "why"
|
|
||||||
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
|
|
||||||
|
|
||||||
check "forgejo + gh-only client refuses" 1 "cannot speak" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
check "the refusal names the forge" 1 "forgejo" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
# The interpolated client, not the bare string "gh" — which also appears in
|
|
||||||
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
|
|
||||||
# stayed green even if the client name never reached the message. Same class
|
|
||||||
# as the "names both totals" weakness the panel caught in the backend suite
|
|
||||||
# (#4727 / #4734); found by auditing this file for the same shape.
|
|
||||||
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
# The refusal must be actionable, not merely loud: #188's whole cost was a
|
|
||||||
# red check that told nobody what to do.
|
|
||||||
check "the refusal names the issue" 1 "#188" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
|
|
||||||
# --- forge_preflight: the passing pairs ---------------------------------
|
|
||||||
|
|
||||||
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
|
|
||||||
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
|
|
||||||
# The mirror of the must-fail: a Forgejo client against GitHub is just as
|
|
||||||
# wrong, and symmetric refusal is cheaper than explaining why only one
|
|
||||||
# direction is checked.
|
|
||||||
check "github + rest refuses" 1 "cannot speak" \
|
|
||||||
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
|
|
||||||
|
|
||||||
# --- forge_preflight: it refuses when the forge itself is unknown --------
|
|
||||||
# Detection failure must not be swallowed into a pass — that would restore
|
|
||||||
# the blind sweep through the back door.
|
|
||||||
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
|
|
||||||
|
|
||||||
# --- forge_client: what each backend actually needs ----------------------
|
|
||||||
# Measured in the runner image the Forgejo instance actually uses
|
|
||||||
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
|
|
||||||
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
|
|
||||||
# necessity, not preference — this is the measurement that retired option
|
|
||||||
# A (port to stoke) as well: the CLI is not on the runner either.
|
|
||||||
|
|
||||||
check "github backend wants gh" 0 "" eq gh forge_client github
|
|
||||||
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
|
|
||||||
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
|
|
||||||
|
|
||||||
# The missing-binary arm, hermetically: an empty PATH has no client at all.
|
|
||||||
check "a forge whose client is not installed refuses" 1 "is not installed" \
|
|
||||||
preflight_bare CEREMONY_FORGE=github
|
|
||||||
check "...and names the missing binary" 1 "gh" preflight_bare CEREMONY_FORGE=github
|
|
||||||
check "...the forgejo arm names its own tools" 1 "curl" preflight_bare CEREMONY_FORGE=forgejo
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -34,13 +34,3 @@ summary() {
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# forge_stub_path <endpoint> — strip the paging parameters the forge shim
|
|
||||||
# injects (#188) so a fixture keyed on the logical endpoint still matches.
|
|
||||||
# The page size moved OUT of the call sites and into the backend, which means
|
|
||||||
# every stub now sees "?per_page=100" appended to a paginated read; without
|
|
||||||
# this, a fixture lookup misses and the stub answers "unreadable", which the
|
|
||||||
# production code correctly reports as a degraded read.
|
|
||||||
forge_stub_path() {
|
|
||||||
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,93 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# The bootstrap bridge across the workflow_call boundary (#215).
|
|
||||||
#
|
|
||||||
# The defect: a called workflow cannot read the caller's dispatch inputs on
|
|
||||||
# this forge — `github.event.inputs.*` is empty inside `workflow_call` even
|
|
||||||
# though the top-level caller receives the value in both contexts (probe runs
|
|
||||||
# 6/7). The old gate read exactly that, so every dispatch-woken sweep
|
|
||||||
# bootstrapped (runs 459/523). The fix moves the value through a DECLARED
|
|
||||||
# `workflow_call` input, passed by the caller, with empty mapped to "no" at
|
|
||||||
# the caller so a cron can never bootstrap.
|
|
||||||
#
|
|
||||||
# These cases pin the wiring at every hop and drive the four value paths
|
|
||||||
# through the semantics of the exact expressions shipped — extracted from the
|
|
||||||
# YAML, never retyped, so an edited expression is an edited test input.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
|
|
||||||
REUSABLE="$ROOT/.github/workflows/labels-sweep.yml"
|
|
||||||
CALLER="$ROOT/.github/workflows/self-labels-sweep.yml"
|
|
||||||
CONSUMERS="$ROOT/docs/CONSUMERS.md"
|
|
||||||
|
|
||||||
# --- the declared boundary ---------------------------------------------------
|
|
||||||
decl() { yq -r ".on.workflow_call.inputs.bootstrap.$1 // \"\"" "$REUSABLE"; }
|
|
||||||
declares_boundary() { [ -n "$(yq -r '.on.workflow_call.inputs.bootstrap // ""' "$REUSABLE")" ]; }
|
|
||||||
check "labels-sweep.yml declares bootstrap as a workflow_call input" 0 "" declares_boundary
|
|
||||||
check "...typed string" 0 "string" decl type
|
|
||||||
check "...defaulting to no — an absent pass-through must never bootstrap" 0 "no" \
|
|
||||||
decl default
|
|
||||||
|
|
||||||
# --- the gate reads the declared input, and nothing else ---------------------
|
|
||||||
gate_exprs() { yq -r '.jobs[].steps[] | select(.with.bootstrap != null) | .with.bootstrap' "$REUSABLE"; }
|
|
||||||
gates_are_identity() {
|
|
||||||
[ "$(gate_exprs | sort -u)" = "\${{ inputs.bootstrap }}" ] \
|
|
||||||
&& [ "$(gate_exprs | wc -l)" -eq 2 ]
|
|
||||||
}
|
|
||||||
check "both gate sites feed the DECLARED input, unchanged" 0 "" gates_are_identity
|
|
||||||
# The forbidden context is only live inside an expression: the file NAMES it
|
|
||||||
# in comments and in the declared input's description to explain the defect,
|
|
||||||
# and both are prose. Matching raw text asserted on the explanation — the
|
|
||||||
# adjacent-assertion trap this suite keeps re-learning — so the predicate is
|
|
||||||
# scoped to `${{ … }}` bodies.
|
|
||||||
reads_event_inputs() { grep -qE '\$\{\{[^}]*github\.event\.inputs' "$REUSABLE"; }
|
|
||||||
check "no expression in the reusable reads github.event.inputs — the context this forge empties" 1 "" \
|
|
||||||
reads_event_inputs
|
|
||||||
|
|
||||||
# --- the caller passes it through, empty mapped to no ------------------------
|
|
||||||
CALLER_EXPR="$(yq -r '.jobs.sweep.with.bootstrap // ""' "$CALLER")"
|
|
||||||
check "self-labels-sweep.yml passes with.bootstrap through the boundary" 0 "" \
|
|
||||||
test -n "$CALLER_EXPR"
|
|
||||||
check "...with the exact empty-guard expression" 0 "" \
|
|
||||||
test "$CALLER_EXPR" = "\${{ inputs.bootstrap || 'no' }}"
|
|
||||||
|
|
||||||
# The published stub must carry the same bridge, or every consumer inherits
|
|
||||||
# the defect ceremony just fixed for itself.
|
|
||||||
check "the CONSUMERS.md sweep stub passes bootstrap through the boundary" 0 \
|
|
||||||
"bootstrap: \${{ inputs.bootstrap || 'no' }}" \
|
|
||||||
grep -F "bootstrap: \${{ inputs.bootstrap || 'no' }}" "$CONSUMERS"
|
|
||||||
|
|
||||||
# --- the four value paths, through the shipped expressions -------------------
|
|
||||||
# Evaluate the caller expression's semantics for a given top-level value. The
|
|
||||||
# expression is asserted byte-exact above, so modelling `x || 'no'` here is
|
|
||||||
# modelling the string the tree actually ships, not a hope about it.
|
|
||||||
caller_pass() { [ -n "$1" ] && printf '%s' "$1" || printf 'no'; }
|
|
||||||
# The reusable's gate is asserted to be the identity; the value then meets
|
|
||||||
# actions/labels-reconcile's REAL validate step, extracted and executed.
|
|
||||||
VALIDATE="$(mktemp)"
|
|
||||||
trap 'rm -f "$VALIDATE"' EXIT
|
|
||||||
{
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash'
|
|
||||||
yq -r '.runs.steps[] | select(.name == "validate bootstrap input") | .run' \
|
|
||||||
"$ROOT/actions/labels-reconcile/action.yml"
|
|
||||||
} >"$VALIDATE"
|
|
||||||
chmod +x "$VALIDATE"
|
|
||||||
path() { BOOTSTRAP="$(caller_pass "$1")" bash "$VALIDATE"; }
|
|
||||||
|
|
||||||
check "schedule (empty top-level context) validates as a non-bootstrap sweep" 0 "" path ""
|
|
||||||
check "a REST event wake passing no validates as a non-bootstrap sweep" 0 "" path no
|
|
||||||
check "a manual dispatch passing yes validates as a bootstrap" 0 "" path yes
|
|
||||||
invalid_path() { BOOTSTRAP="maybe" bash "$VALIDATE"; }
|
|
||||||
check "an invalid value reaches the validator UNSANITIZED and refuses" 2 \
|
|
||||||
"bootstrap must be 'yes' or 'no'" invalid_path
|
|
||||||
|
|
||||||
# ...and the non-bootstrap/bootstrap split is what the validator's callers
|
|
||||||
# act on: prove the two accepted values are distinguished, not merely both
|
|
||||||
# accepted, by pinning what each resolves to after the caller pass.
|
|
||||||
check "empty and no resolve identically — the cron can never bootstrap" 0 "" \
|
|
||||||
test "$(caller_pass "")" = "$(caller_pass no)"
|
|
||||||
check "...and yes stays yes through the pass" 0 "" test "$(caller_pass yes)" = yes
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# The sweep dispatch in .github/workflows/labels.yml (#205).
|
|
||||||
#
|
|
||||||
# This EXTRACTS the shipped step's `run:` script and EXECUTES it against a
|
|
||||||
# recording stub, rather than grepping the YAML for strings. A grep here would
|
|
||||||
# pass on a step that assembles a perfect request and never sends it — the
|
|
||||||
# shape of defect this repo keeps finding in its own tests. So every case
|
|
||||||
# asserts on what the step actually sent, or on what it actually did when the
|
|
||||||
# forge refused.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
|
|
||||||
WORKFLOW="$ROOT/.github/workflows/labels.yml"
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "${TMP:?}"' EXIT
|
|
||||||
|
|
||||||
# --- the step under test, taken from the shipped workflow --------------------
|
|
||||||
STEP="$TMP/step.sh"
|
|
||||||
{
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
|
|
||||||
yq -r '.jobs.trigger.steps[] | select(.name == "dispatch the sweep") | .run' "$WORKFLOW"
|
|
||||||
} >"$STEP"
|
|
||||||
chmod +x "$STEP"
|
|
||||||
|
|
||||||
step_extracted() { [ "$(wc -l <"$STEP")" -ge 10 ]; }
|
|
||||||
check "the step's script was extracted from the shipped workflow" 0 "" step_extracted
|
|
||||||
|
|
||||||
# --- stubs -------------------------------------------------------------------
|
|
||||||
# `curl` records every invocation and answers with the code the case wants. It
|
|
||||||
# parses only what the step actually passes, so a step that stopped sending
|
|
||||||
# `-d`, or changed the method, fails here rather than recording nothing.
|
|
||||||
make_curl() { # make_curl <http-code> <body>
|
|
||||||
cat >"$TMP/bin/curl" <<STUB
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
method=GET; data=; url=; out=; write=
|
|
||||||
while [ \$# -gt 0 ]; do
|
|
||||||
case "\$1" in
|
|
||||||
-X) method="\$2"; shift 2 ;;
|
|
||||||
-d) data="\$2"; shift 2 ;;
|
|
||||||
-o) out="\$2"; shift 2 ;;
|
|
||||||
-w) write="\$2"; shift 2 ;;
|
|
||||||
-H) printf '%s\n' "HEADER \$2" >>"$TMP/calls"; shift 2 ;;
|
|
||||||
-fsS|-sS|-s) shift ;;
|
|
||||||
*) url="\$1"; shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
printf '%s\n' "METHOD \$method" "URL \$url" "DATA \$data" >>"$TMP/calls"
|
|
||||||
# the default-branch read is a plain GET whose stdout the step pipes to jq
|
|
||||||
if [ "\$method" = GET ]; then printf '{"default_branch":"trunk"}'; exit 0; fi
|
|
||||||
[ -n "\$out" ] && printf '%s' '$2' >"\$out"
|
|
||||||
[ -n "\$write" ] && printf '%s' '$1'
|
|
||||||
exit 0
|
|
||||||
STUB
|
|
||||||
chmod +x "$TMP/bin/curl"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_step() { # run_step <http-code> <body> [env assignments...]
|
|
||||||
local code="$1" body="$2"
|
|
||||||
shift 2
|
|
||||||
rm -rf "${TMP:?}/bin"
|
|
||||||
mkdir -p "$TMP/bin"
|
|
||||||
: >"$TMP/calls"
|
|
||||||
make_curl "$code" "$body"
|
|
||||||
env PATH="$TMP/bin:$PATH" \
|
|
||||||
GITHUB_TOKEN=tok \
|
|
||||||
GITHUB_API_URL=https://forge.example/api/v1 \
|
|
||||||
GITHUB_REPOSITORY=owner/repo \
|
|
||||||
SWEEP_WORKFLOW=self-labels-sweep.yml \
|
|
||||||
"$@" \
|
|
||||||
"$STEP"
|
|
||||||
}
|
|
||||||
|
|
||||||
sent() { grep -h "^$1 " "$TMP/calls" | tail -1 | cut -d' ' -f2-; }
|
|
||||||
sent_field() { jq -r "$1" <<<"$(sent DATA)"; }
|
|
||||||
|
|
||||||
# --- the success path --------------------------------------------------------
|
|
||||||
check "a 204 dispatch succeeds, naming what it woke and where" 0 \
|
|
||||||
"sweep dispatched — self-labels-sweep.yml on main" \
|
|
||||||
run_step 204 "" DEFAULT_BRANCH=main
|
|
||||||
|
|
||||||
posted() { [ "$(sent METHOD)" = POST ]; }
|
|
||||||
check "...by POST, not GET" 0 "" posted
|
|
||||||
|
|
||||||
right_endpoint() {
|
|
||||||
[ "$(sent URL)" = \
|
|
||||||
"https://forge.example/api/v1/repos/owner/repo/actions/workflows/self-labels-sweep.yml/dispatches" ]
|
|
||||||
}
|
|
||||||
check "...to the dispatches endpoint of the workflow it was told to wake" 0 "" right_endpoint
|
|
||||||
|
|
||||||
ref_is_main() { [ "$(sent_field .ref)" = main ]; }
|
|
||||||
check "...carrying a ref, because REST has no default and refuses without one" 0 "" ref_is_main
|
|
||||||
|
|
||||||
bootstrap_is_string_no() { [ "$(sent_field '.inputs.bootstrap')" = no ]; }
|
|
||||||
check "...and bootstrap=no as a STRING input, not a bare flag" 0 "" bootstrap_is_string_no
|
|
||||||
|
|
||||||
bearer_sent() { grep -qF 'HEADER Authorization: Bearer tok' "$TMP/calls"; }
|
|
||||||
check "...under the bearer header both forges accept" 0 "" bearer_sent
|
|
||||||
|
|
||||||
# --- the ref it must NOT inherit ---------------------------------------------
|
|
||||||
# On pull_request_target GITHUB_REF_NAME is `<n>/merge`. A step that reaches
|
|
||||||
# for it dispatches at something that is not a branch — and the forge answers
|
|
||||||
# that with the opaque 500, so it would look like an outage.
|
|
||||||
check "a pull_request_target run still dispatches at the branch" 0 "" \
|
|
||||||
run_step 204 "" DEFAULT_BRANCH=main GITHUB_REF_NAME=203/merge
|
|
||||||
ref_is_not_a_merge_ref() { case "$(sent_field .ref)" in *merge*) return 1 ;; *) return 0 ;; esac; }
|
|
||||||
check "...never at its merge ref" 0 "" ref_is_not_a_merge_ref
|
|
||||||
|
|
||||||
# --- the fallback ------------------------------------------------------------
|
|
||||||
check "an absent default branch is read from the forge, not guessed" 0 "" \
|
|
||||||
run_step 204 "" DEFAULT_BRANCH=
|
|
||||||
ref_is_trunk() { [ "$(sent_field .ref)" = trunk ]; }
|
|
||||||
check "...and the dispatch uses what the read returned" 0 "" ref_is_trunk
|
|
||||||
|
|
||||||
# --- failure is loud, and the diagnostic is owned ----------------------------
|
|
||||||
# `gh workflow run` failing WAS the misconfiguration alarm. The port keeps that
|
|
||||||
# contract: a consumer missing the caller, its input, or `actions: write` must
|
|
||||||
# fail here rather than sweep silently never again.
|
|
||||||
check "an empty 500 fails the step — the alarm still rings" 1 \
|
|
||||||
"/self-labels-sweep.yml/dispatches (ref=main)" \
|
|
||||||
run_step 500 "" DEFAULT_BRANCH=main
|
|
||||||
check "...explaining Forgejo's EMPTY 500 rather than passing it through" 1 \
|
|
||||||
"empty 500 body from Forgejo means the workflow name or the ref did not resolve" \
|
|
||||||
run_step 500 "" DEFAULT_BRANCH=main
|
|
||||||
check "...and naming the consumer causes the alarm exists for" 1 "actions: write" \
|
|
||||||
run_step 500 "" DEFAULT_BRANCH=main
|
|
||||||
check "any non-204 fails, not only the statuses the API documents" 1 "forbidden" \
|
|
||||||
run_step 403 '{"message":"forbidden"}' DEFAULT_BRANCH=main
|
|
||||||
|
|
||||||
# --- the API root is not guessed ---------------------------------------------
|
|
||||||
# Defaulting an unset GITHUB_API_URL to api.github.com sent this forge's
|
|
||||||
# dispatch to GitHub and reported success (@codex-reviewer-andresmgsl). The
|
|
||||||
# teeth are the call count: refusing AFTER a request is not refusing.
|
|
||||||
run_step_no_api() {
|
|
||||||
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
|
|
||||||
make_curl 204 ""
|
|
||||||
# -u, because plain `env` PRESERVES the parent environment: on the runner
|
|
||||||
# every step arrives with GITHUB_API_URL set — the premise of the fix under
|
|
||||||
# test — so without the unset this case inherits it, the refusal path never
|
|
||||||
# executes, and the case passes only in a dev shell that lacks the variable
|
|
||||||
# (@kimi-reviewer-andresmgsl, run 468).
|
|
||||||
env -u GITHUB_API_URL \
|
|
||||||
PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok GITHUB_REPOSITORY=owner/repo \
|
|
||||||
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
|
|
||||||
}
|
|
||||||
check "an unset GITHUB_API_URL refuses rather than guessing GitHub" 1 \
|
|
||||||
"GITHUB_API_URL is unset" run_step_no_api
|
|
||||||
no_calls_made() { [ ! -s "$TMP/calls" ]; }
|
|
||||||
check "...having made zero requests: refusing after a POST is not refusing" 0 "" no_calls_made
|
|
||||||
|
|
||||||
# --- a transport failure is not silence --------------------------------------
|
|
||||||
# The old `|| true` invariant was asserted by grepping the gh line this port
|
|
||||||
# removed, so it passed on any REST implementation including one that swallows
|
|
||||||
# a failed POST (@codex-reviewer-andresmgsl). Driven instead: curl itself exits
|
|
||||||
# non-zero, which `-w` cannot report because nothing is written.
|
|
||||||
make_failing_curl() {
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'echo "curl: (7) failed to connect" >&2' 'exit 7' \
|
|
||||||
>"$TMP/bin/curl"
|
|
||||||
chmod +x "$TMP/bin/curl"
|
|
||||||
}
|
|
||||||
run_step_curl_dies() {
|
|
||||||
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
|
|
||||||
make_failing_curl
|
|
||||||
env PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok \
|
|
||||||
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_REPOSITORY=owner/repo \
|
|
||||||
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
|
|
||||||
}
|
|
||||||
check "a POST that never reaches the forge fails the step, and says so" 1 \
|
|
||||||
"never completed" run_step_curl_dies
|
|
||||||
|
|
||||||
# A code-aware guard alongside the behavioural one: no swallowing operator on
|
|
||||||
# the dispatch itself.
|
|
||||||
never_silenced() { sed 's/#.*//' "$STEP" | grep -qE '\|\|[[:space:]]*true'; }
|
|
||||||
check "...and the step carries no || true" 1 "" never_silenced
|
|
||||||
|
|
||||||
# --- what the port removed ---------------------------------------------------
|
|
||||||
# Strip comments first: the step's prose NAMES `gh workflow run` and
|
|
||||||
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
|
|
||||||
# the explanation instead of the code.
|
|
||||||
step_code() { sed 's/#.*//' "$STEP"; }
|
|
||||||
invokes_gh() { step_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
|
|
||||||
decides_forge() { step_code | grep -qE 'GITHUB_SERVER_URL|CEREMONY_FORGE_CLIENT'; }
|
|
||||||
check "the step no longer INVOKES gh, its comments about it aside" 1 "" invokes_gh
|
|
||||||
check "...and no longer decides a forge, because REST needs no branch" 1 "" decides_forge
|
|
||||||
|
|
||||||
summary
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -107,125 +107,6 @@ EOF
|
||||||
check "derive: the real mapping labels this test file" 0 \
|
check "derive: the real mapping labels this test file" 0 \
|
||||||
"scope:labels" derive_labels "$real_rows" 'test/labels-scope.test.sh'
|
"scope:labels" derive_labels "$real_rows" 'test/labels-scope.test.sh'
|
||||||
|
|
||||||
# --- the real mapping locates: one file set in, the whole label set out ---
|
|
||||||
# #267 measured the old map at 100% recall / 15% precision — 20 of the last
|
|
||||||
# 20 PRs wore scope:release-flow and 3 touched a release surface — so these
|
|
||||||
# cases assert the DERIVED SET WHOLE, brackets and all. A substring check
|
|
||||||
# cannot tell scope:labels from scope:labels plus a wrong second label, and
|
|
||||||
# a wrong second label is the whole defect.
|
|
||||||
derives() { # <newline-separated paths> → "[label,label]" for the real map
|
|
||||||
printf '[%s]\n' "$(derive_labels "$real_rows" "$1" | paste -sd, -)"
|
|
||||||
}
|
|
||||||
files() { printf '%s\n' "$@"; }
|
|
||||||
|
|
||||||
# D1: a fragment is written by every behavior change (BUILDER.md), so it
|
|
||||||
# carries no locating information. Asserted as an empty set on its own, not
|
|
||||||
# as an absence inside a longer list: this is the case that fails first if
|
|
||||||
# the glob is ever restored.
|
|
||||||
check "derive: a fragment-only path derives nothing at all" 0 \
|
|
||||||
"[]" derives 'changelog.d/999.md'
|
|
||||||
|
|
||||||
# D2: the issue-flow sweep is a reconciler of the label taxonomy
|
|
||||||
check "derive: the issueflow reconciler is scope:labels" 0 \
|
|
||||||
"[scope:labels]" derives 'actions/issueflow-reconcile/issueflow-reconcile.sh'
|
|
||||||
check "derive: the issueflow reconciler's test is scope:labels" 0 \
|
|
||||||
"[scope:labels]" derives 'test/issueflow-reconcile.test.sh'
|
|
||||||
|
|
||||||
# the reported bug, replayed: #261's exact file set wore scope:release-flow,
|
|
||||||
# inherited from its fragment, pointing at the one surface it does not touch
|
|
||||||
check "derive: #261's file set is scope:labels alone" 0 "[scope:labels]" \
|
|
||||||
derives "$(files actions/issueflow-reconcile/issueflow-reconcile.sh \
|
|
||||||
changelog.d/252.md test/issueflow-reconcile.test.sh)"
|
|
||||||
|
|
||||||
# D1's cost, checked rather than assumed: dropping the fragment glob must
|
|
||||||
# not cost the release surface its label
|
|
||||||
check "derive: a release PR is still scope:release-flow" 0 \
|
|
||||||
"[scope:release-flow]" \
|
|
||||||
derives "$(files VERSION CHANGELOG.md drills/0.6.0.md changelog.d/236.md)"
|
|
||||||
|
|
||||||
# D3: the docs block matched a literal README this tree does not have
|
|
||||||
check "derive: README.md is scope:docs" 0 "[scope:docs]" derives 'README.md'
|
|
||||||
check "derive: RELEASES.md is scope:docs" 0 "[scope:docs]" derives 'RELEASES.md'
|
|
||||||
check "derive: TRIAGE.md is scope:docs" 0 "[scope:docs]" derives 'TRIAGE.md'
|
|
||||||
|
|
||||||
# D3: three guard actions and their tests were in no block at all. Each of
|
|
||||||
# the six paths is asserted ALONE, never bundled with its sibling: a set
|
|
||||||
# holding both the action and its test derives scope:guards when either row
|
|
||||||
# matches, so one row could be deleted with the case still green — the six
|
|
||||||
# rows have to be six assertions to be six protections (#300 round).
|
|
||||||
for guard in changelog-assembled docs-sync runner-isolated; do
|
|
||||||
check "derive: actions/$guard is scope:guards" 0 "[scope:guards]" \
|
|
||||||
derives "actions/$guard/$guard.sh"
|
|
||||||
check "derive: $guard's test is scope:guards" 0 "[scope:guards]" \
|
|
||||||
derives "test/$guard.test.sh"
|
|
||||||
done
|
|
||||||
|
|
||||||
# D4: lib/ is genuinely mixed, so the shared files wear both labels rather
|
|
||||||
# than lib/** being re-carved into a row per file
|
|
||||||
check "derive: lib/ruling.sh is release-flow AND labels" 0 \
|
|
||||||
"[scope:release-flow,scope:labels]" derives 'lib/ruling.sh'
|
|
||||||
check "derive: lib/read.sh is release-flow AND labels" 0 \
|
|
||||||
"[scope:release-flow,scope:labels]" derives 'lib/read.sh'
|
|
||||||
check "derive: lib/version.sh is release-flow only" 0 \
|
|
||||||
"[scope:release-flow]" derives 'lib/version.sh'
|
|
||||||
|
|
||||||
# D6: the map stays advisory. An unmapped path derives an empty set and
|
|
||||||
# exits 0 — a guard that redded here would fail every PR touching FLEET.md
|
|
||||||
# or ci.yml, neither of which this map claims.
|
|
||||||
check "derive: an unmapped path is silence, not an error" 0 "[]" \
|
|
||||||
derives "$(files FLEET.md .github/workflows/ci.yml)"
|
|
||||||
|
|
||||||
# --- #302: one wrong answer and the surfaces the map never learned ------
|
|
||||||
# Every path asserted ALONE, per #300 round 1: a set holding a script and
|
|
||||||
# its test derives the scope when either row matches, so bundling would
|
|
||||||
# let a row be deleted with the case still green.
|
|
||||||
|
|
||||||
# D1, the reported bug replayed: both reconcilers source lib/attention.sh,
|
|
||||||
# nothing release-side does — [scope:release-flow] alone was a wrong
|
|
||||||
# answer, and the honest set is both, same as its two shelf-mates
|
|
||||||
check "derive: lib/attention.sh is release-flow AND labels" 0 \
|
|
||||||
"[scope:release-flow,scope:labels]" derives 'lib/attention.sh'
|
|
||||||
|
|
||||||
# D2: the sweep half of the automation, detached from the trigger half in
|
|
||||||
# #209 — cadence, permissions and job wiring must locate
|
|
||||||
check "derive: the labels sweep workflow is scope:labels" 0 \
|
|
||||||
"[scope:labels]" derives '.github/workflows/labels-sweep.yml'
|
|
||||||
check "derive: the self sweep workflow is scope:labels" 0 \
|
|
||||||
"[scope:labels]" derives '.github/workflows/self-labels-sweep.yml'
|
|
||||||
|
|
||||||
# D3, the deliberate asymmetry with D1: a test file inherits no lib/**
|
|
||||||
# glob, so its row is the one scope its subject actually locates
|
|
||||||
check "derive: attention's test is scope:labels alone" 0 \
|
|
||||||
"[scope:labels]" derives 'test/attention.test.sh'
|
|
||||||
check "derive: ruling's test is scope:labels alone" 0 \
|
|
||||||
"[scope:labels]" derives 'test/ruling.test.sh'
|
|
||||||
|
|
||||||
# D4: the same read's remaining gaps, one row each
|
|
||||||
check "derive: the trigger-surface pins are scope:labels" 0 \
|
|
||||||
"[scope:labels]" derives 'test/labels-triggers.test.sh'
|
|
||||||
check "derive: the assemble test is scope:release-flow" 0 \
|
|
||||||
"[scope:release-flow]" derives 'test/changelog-assemble.test.sh'
|
|
||||||
check "derive: the release-path manifest is scope:release-flow" 0 \
|
|
||||||
"[scope:release-flow]" derives '.github/scripts/release-path.sh'
|
|
||||||
check "derive: the release-path test is scope:release-flow" 0 \
|
|
||||||
"[scope:release-flow]" derives 'test/release-path.test.sh'
|
|
||||||
check "derive: the marker-check guard is scope:guards" 0 \
|
|
||||||
"[scope:guards]" derives '.github/scripts/marker-check.sh'
|
|
||||||
check "derive: the marker-check test is scope:guards" 0 \
|
|
||||||
"[scope:guards]" derives 'test/marker-check.test.sh'
|
|
||||||
check "derive: the vendored-check guard is scope:guards" 0 \
|
|
||||||
"[scope:guards]" derives '.github/scripts/vendored-check.sh'
|
|
||||||
check "derive: the vendored test is scope:guards" 0 \
|
|
||||||
"[scope:guards]" derives 'test/vendored.test.sh'
|
|
||||||
|
|
||||||
# D7: no test/** or .github/scripts/** catch-all — both directories span
|
|
||||||
# all four scopes, so this pair reds under any catch-all row: each file
|
|
||||||
# would gain the other's scope beside its own
|
|
||||||
check "derive: test/version.test.sh is release-flow alone" 0 \
|
|
||||||
"[scope:release-flow]" derives 'test/version.test.sh'
|
|
||||||
check "derive: this test file is scope:labels alone" 0 \
|
|
||||||
"[scope:labels]" derives 'test/labels-scope.test.sh'
|
|
||||||
|
|
||||||
# refusals: unsupported shapes fail loudly, naming the label
|
# refusals: unsupported shapes fail loudly, naming the label
|
||||||
cat >"$TMP/allglobs.yml" <<'EOF'
|
cat >"$TMP/allglobs.yml" <<'EOF'
|
||||||
scope:x:
|
scope:x:
|
||||||
|
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -u
|
|
||||||
|
|
||||||
# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is
|
|
||||||
# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires
|
|
||||||
# is what exhausted the fleet's shared Actions allotment. These assertions
|
|
||||||
# pin the reductions #199 made and the guard it must not trade away — none of
|
|
||||||
# them touch the reconciler's LOGIC, which its own fixtures cover.
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
source "$ROOT/test/harness.sh"
|
|
||||||
|
|
||||||
REUSABLE="$ROOT/.github/workflows/labels.yml"
|
|
||||||
SWEEP="$ROOT/.github/workflows/labels-sweep.yml"
|
|
||||||
SELF="$ROOT/.github/workflows/self-labels.yml"
|
|
||||||
SELF_SWEEP="$ROOT/.github/workflows/self-labels-sweep.yml"
|
|
||||||
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stubs, fenced yaml blocks
|
|
||||||
|
|
||||||
# The `cancel-in-progress:` value of a named top-level job, read from the first
|
|
||||||
# such line inside that job's block. Job keys sit at two-space indent.
|
|
||||||
job_cancel_in_progress() { # $1 = file, $2 = job name
|
|
||||||
awk -v job="^ $2:\$" '
|
|
||||||
$0 ~ job { f = 1; next }
|
|
||||||
f && /^ [a-z]/ { exit } # next job — stop before leaking into it
|
|
||||||
f && /cancel-in-progress:/ { sub(/.*cancel-in-progress:[[:space:]]*/, ""); print; exit }
|
|
||||||
' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# The `types:` list of a trigger key (issues:, pull_request_target:), read from
|
|
||||||
# the first `types:` line after the bare key. The key is bare (nothing after
|
|
||||||
# the colon) so it never collides with `issues: write` in the permissions block.
|
|
||||||
trigger_types() { # $1 = file, $2 = trigger key
|
|
||||||
awk -v key="^ $2:\$" '
|
|
||||||
$0 ~ key { f = 1; next }
|
|
||||||
f && /^ types:/ { sub(/^ types:[[:space:]]*/, ""); print; exit }
|
|
||||||
f && /^ [a-z]/ { exit }
|
|
||||||
' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
|
|
||||||
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
|
|
||||||
# the shared concurrency group exists to prevent. It WOULD cut run count — by
|
|
||||||
# trading correctness for minutes — so it stays false, forever. The job lives
|
|
||||||
# in labels-sweep.yml since #209; the guard moved with it.
|
|
||||||
check "reconcile serializes, never cancels mid-board" 0 "false" \
|
|
||||||
job_cancel_in_progress "$SWEEP" reconcile
|
|
||||||
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
|
||||||
check "reconcile is never cancel-in-progress: true" 1 "" \
|
|
||||||
bash -c 'job_cancel_in_progress() {
|
|
||||||
awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1"
|
|
||||||
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$SWEEP"
|
|
||||||
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
|
|
||||||
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
|
|
||||||
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
|
|
||||||
job_cancel_in_progress "$REUSABLE" scope
|
|
||||||
|
|
||||||
# ---- the sweep is detached from PR-triggered runs (#209) ---------------------
|
|
||||||
# While reconcile rode the PR-event run, every displacement in its shared
|
|
||||||
# queue recorded a CANCELLED check on some PR — fake red CI. The reusable
|
|
||||||
# labels.yml must never grow the job back; its trigger job wakes the sweep
|
|
||||||
# caller by dispatch instead, and that dispatch is the misconfiguration
|
|
||||||
# alarm: a pin bumped without the sweep caller must go loudly red at the
|
|
||||||
# trigger, so the dispatch line is never allowed to silence itself.
|
|
||||||
check "labels.yml carries no reconcile job" 1 "" \
|
|
||||||
grep -E '^ reconcile:' "$REUSABLE"
|
|
||||||
check "labels-sweep.yml carries the reconcile job" 0 " reconcile:" \
|
|
||||||
grep -E '^ reconcile:' "$SWEEP"
|
|
||||||
check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconcile" \
|
|
||||||
grep -F 'group: labels-reconcile' "$SWEEP"
|
|
||||||
check "labels.yml carries the trigger job" 0 " trigger:" \
|
|
||||||
grep -E '^ trigger:' "$REUSABLE"
|
|
||||||
# #205 ported this dispatch from `gh workflow run` to REST. The assertion is
|
|
||||||
# the same one it always was — the sweep caller is woken BY NAME and never
|
|
||||||
# bootstrapped — but it now has to hold against a request rather than a CLI
|
|
||||||
# line. What the step actually SENDS is driven in test/labels-dispatch.test.sh;
|
|
||||||
# these two keep the wiring pinned here alongside the rest of the trigger.
|
|
||||||
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
|
|
||||||
check "the trigger dispatches the sweep caller by name" 0 \
|
|
||||||
'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
|
|
||||||
grep -F '/dispatches' "$REUSABLE"
|
|
||||||
check "...never bootstrapping" 0 'bootstrap: "no"' \
|
|
||||||
grep -F 'bootstrap' "$REUSABLE"
|
|
||||||
# The never-silenced invariant moved to test/labels-dispatch.test.sh, where it
|
|
||||||
# is BEHAVIOURAL: a curl that dies at the transport must fail the extracted
|
|
||||||
# step, plus a code-aware no-`|| true` guard on the step itself. The check that
|
|
||||||
# lived here grepped the `gh workflow run` line #205 removed, so after the port
|
|
||||||
# it passed on every implementation including one that swallows a failed POST —
|
|
||||||
# a green assertion whose name claimed an invariant its implementation could
|
|
||||||
# not observe (@codex-reviewer-andresmgsl, #213 review).
|
|
||||||
check "the sweep caller filename input defaults to labels-sweep.yml" 0 \
|
|
||||||
"default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE"
|
|
||||||
# the dogfood callers wear the split: the event caller names its deviant
|
|
||||||
# sweep filename, and the sweep caller declares the bootstrap input the
|
|
||||||
# trigger's -f flag requires (an undeclared input reds every dispatch)
|
|
||||||
check "self caller passes its dogfood sweep filename" 0 \
|
|
||||||
"sweep_workflow: self-labels-sweep.yml" \
|
|
||||||
grep -F 'sweep_workflow: self-labels-sweep.yml' "$SELF"
|
|
||||||
check "self sweep caller declares the bootstrap dispatch input" 0 \
|
|
||||||
"bootstrap:" grep -E '^ bootstrap:' "$SELF_SWEEP"
|
|
||||||
check "stub sweep caller declares the bootstrap dispatch input" 0 \
|
|
||||||
"bootstrap:" grep -E '^ bootstrap:' "$STUB"
|
|
||||||
# the labels caller's event runs must not carry the sweep's cron or manual
|
|
||||||
# dispatch — those relocated to the sweep caller with #209
|
|
||||||
check "self caller carries no cron" 1 "" grep -F 'cron:' "$SELF"
|
|
||||||
check "self caller carries no workflow_dispatch" 1 "" \
|
|
||||||
grep -E '^ workflow_dispatch:' "$SELF"
|
|
||||||
|
|
||||||
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
|
|
||||||
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
|
|
||||||
# name to explain the change, and must not re-red their own documentation.
|
|
||||||
# The cron rides the sweep caller since #209.
|
|
||||||
check "self sweep caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF_SWEEP"
|
|
||||||
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
|
||||||
check "self sweep caller cron line no longer fires */15" 1 "" \
|
|
||||||
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF_SWEEP"
|
|
||||||
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB"
|
|
||||||
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
|
||||||
check "stub cron line no longer fires */15" 1 "" \
|
|
||||||
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$STUB"
|
|
||||||
|
|
||||||
# ---- issues: is narrowed to the queue-state-changing actions (#199) ----------
|
|
||||||
# Kept because each carries a queue-state change an event uniquely carries, so
|
|
||||||
# dropping it would trip #199's must-fail (a transition waiting on the schedule
|
|
||||||
# when an event could have carried it): opened → mint→needs-triage; closed →
|
|
||||||
# blocker-closes→ready self-heal; edited → a body rewrite of the `Blocked by #N`
|
|
||||||
# declaration the sweep parses; reopened → a closed issue re-entering the queue.
|
|
||||||
# (labels.test.sh owns the exact-list and caller<->stub parity assertions.)
|
|
||||||
for keep in opened closed edited reopened; do
|
|
||||||
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
|
||||||
check "self caller issues surface keeps '$keep'" 0 "" \
|
|
||||||
bash -c 'trigger_types() {
|
|
||||||
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
|
|
||||||
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$keep"
|
|
||||||
done
|
|
||||||
# The churn actions must not reappear on the issues surface without a fresh why.
|
|
||||||
# labeled/unlabeled were the dominant issues-churn source; assigned/unassigned
|
|
||||||
# only feed validation and the 48h claim clock, caught within one cadence.
|
|
||||||
for churn in labeled unlabeled assigned unassigned; do
|
|
||||||
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
|
||||||
check "self caller issues surface drops '$churn'" 1 "" \
|
|
||||||
bash -c 'trigger_types() {
|
|
||||||
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
|
|
||||||
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$churn"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---- the PR handoff wake is NOT collateral of the issues narrowing ----------
|
|
||||||
# The handoff (state:needs-human, confirmed by the caller's labeled event) rides
|
|
||||||
# pull_request_target, not issues. A future edit that strips it there re-reds.
|
|
||||||
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
|
|
||||||
trigger_types "$SELF" pull_request_target
|
|
||||||
|
|
||||||
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
|
|
||||||
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
|
|
||||||
# fork-headed pull_request_target run must attempt no write: both write-capable
|
|
||||||
# jobs exclude it, while one successful job explains exactly what the scheduled
|
|
||||||
# sweep does and does not supply. Require each full normalised expression to
|
|
||||||
# appear intact, so deleting or inverting one of its clauses fails the guard.
|
|
||||||
job_if_expression() { # $1 = file, $2 = job
|
|
||||||
yq -r ".jobs.$2.if // \"\"" "$1" |
|
|
||||||
tr '\n' ' ' |
|
|
||||||
awk '{$1=$1; print}'
|
|
||||||
}
|
|
||||||
check "scope writes only for a same-repo PR head" 0 \
|
|
||||||
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \
|
|
||||||
job_if_expression "$REUSABLE" scope
|
|
||||||
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \
|
|
||||||
"github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \
|
|
||||||
job_if_expression "$REUSABLE" trigger
|
|
||||||
check "a fork-headed PR selects the successful explanation job" 0 \
|
|
||||||
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \
|
|
||||||
job_if_expression "$REUSABLE" fork_head
|
|
||||||
|
|
||||||
fork_head_step() {
|
|
||||||
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
|
|
||||||
"$REUSABLE" | bash
|
|
||||||
}
|
|
||||||
check "the fork path distinguishes swept state from unsupported scope writes" 0 \
|
|
||||||
"read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \
|
|
||||||
fork_head_step
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -31,15 +31,6 @@ attention_row='attention|D93F0B|A demand is parked here for the assignee: pick u
|
||||||
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
|
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
|
||||||
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
|
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row"
|
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row"
|
||||||
needs_triage_row='needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal'
|
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
|
||||||
check "needs-triage core row is emitted once, byte-exact" 0 "1" bash -c \
|
|
||||||
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
|
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$needs_triage_row"
|
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
|
||||||
check "core label rows carry no stale discussion path" 1 "" bash -c \
|
|
||||||
'source "$1"; core_label_rows | grep -i discussion' _ \
|
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh"
|
|
||||||
# shellcheck disable=SC2016 # fields are intentionally split in the nested shell
|
# shellcheck disable=SC2016 # fields are intentionally split in the nested shell
|
||||||
check "attention description survives label field splitting" 0 \
|
check "attention description survives label field splitting" 0 \
|
||||||
"A demand is parked here for the assignee: pick up the thread, ack by removing this label" \
|
"A demand is parked here for the assignee: pick up the thread, ack by removing this label" \
|
||||||
|
|
@ -62,82 +53,6 @@ load_config "$TMP/good.conf"
|
||||||
set_required_bots two
|
set_required_bots two
|
||||||
check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}"
|
check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}"
|
||||||
|
|
||||||
# -- per-author panel rows (#224): the config-parse matrix -------------------
|
|
||||||
# required_for loads a conf fresh in a subshell and prints the required set
|
|
||||||
# behind a RESULT: anchor, so substring matching cannot confuse "b c" with
|
|
||||||
# "a b c".
|
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
|
||||||
required_for() { # $1 = conf, $2 = author → RESULT:<required set>
|
|
||||||
bash -c 'source "$1"; load_config "$2" || exit 1
|
|
||||||
set_required_bots "$3"; printf "RESULT:%s\n" "${REQUIRED_BOTS[*]}"' _ \
|
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$1" "$2"
|
|
||||||
}
|
|
||||||
printf '%s\n' 'panel=a b c' >"$TMP/plain.conf"
|
|
||||||
check "no bracketed row: panelist author gets panel minus self" 0 "RESULT:b c" \
|
|
||||||
required_for "$TMP/plain.conf" a
|
|
||||||
check "no bracketed row: outside author gets the whole panel" 0 "RESULT:a b c" \
|
|
||||||
required_for "$TMP/plain.conf" z
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[z]=b c' >"$TMP/author.conf"
|
|
||||||
check "bracketed author gets exactly its row" 0 "RESULT:b c" \
|
|
||||||
required_for "$TMP/author.conf" z
|
|
||||||
check "unbracketed author beside a bracketed row is unchanged" 0 "RESULT:b c" \
|
|
||||||
required_for "$TMP/author.conf" a
|
|
||||||
printf '%s\n' 'panel[z]=b c' 'panel=a b c' >"$TMP/reversed.conf"
|
|
||||||
check "row order is irrelevant: bracketed row before panel=" 0 "RESULT:b c" \
|
|
||||||
required_for "$TMP/reversed.conf" z
|
|
||||||
check "row order is irrelevant for the base panel too" 0 "RESULT:b c" \
|
|
||||||
required_for "$TMP/reversed.conf" a
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[a]=a b' >"$TMP/self.conf"
|
|
||||||
check "author inside its own bracketed row is still recused" 0 "RESULT:b" \
|
|
||||||
required_for "$TMP/self.conf" a
|
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
|
||||||
check "base panel is byte-identical with the bracketed rows deleted" 0 "SAME" \
|
|
||||||
bash -c 'source "$1"; load_config "$2"; with="${BOTS[*]}"
|
|
||||||
load_config "$3"; [ "$with" = "${BOTS[*]}" ] && echo SAME' _ \
|
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" \
|
|
||||||
"$TMP/author.conf" "$TMP/plain.conf"
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[z]=b' 'panel[z]=c' >"$TMP/dup-author.conf"
|
|
||||||
check "duplicate rows for one login fail naming the line" 1 \
|
|
||||||
"duplicate panel[z]= row" load_config "$TMP/dup-author.conf"
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[z]=' >"$TMP/empty-set.conf"
|
|
||||||
check "a bracketed row naming zero reviewers fails loudly" 1 \
|
|
||||||
"panel[z]= must name at least one reviewer" load_config "$TMP/empty-set.conf"
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[]=b c' >"$TMP/empty-login.conf"
|
|
||||||
check "an empty login fails loudly" 1 "empty login in panel row" \
|
|
||||||
load_config "$TMP/empty-login.conf"
|
|
||||||
# codex's round-1 probe: the stray ] used to parse, record login z], and
|
|
||||||
# silently misroute z to the base panel — exactly the D4 refusal owed.
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[z]]=b' >"$TMP/stray-bracket.conf"
|
|
||||||
check "a stray ] inside the bracket is refused as a bracket" 1 \
|
|
||||||
"malformed panel[<login>]= row" load_config "$TMP/stray-bracket.conf"
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[a_b]=c' >"$TMP/bad-login.conf"
|
|
||||||
check "a non-login character in the bracket is refused" 1 \
|
|
||||||
"malformed panel[<login>]= row" load_config "$TMP/bad-login.conf"
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[z=b c' >"$TMP/broken-bracket.conf"
|
|
||||||
check "a malformed bracket is refused as a bracket (D4)" 1 \
|
|
||||||
"malformed panel[<login>]= row" load_config "$TMP/broken-bracket.conf"
|
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
|
||||||
check "...and never as a label row" 1 "" bash -c \
|
|
||||||
'source "$1"; load_config "$2" 2>&1 | grep -F "malformed label row"' _ \
|
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/broken-bracket.conf"
|
|
||||||
# The D7 tripwire: in a case pattern an unquoted panel[abc]=* is a bracket
|
|
||||||
# expression matching panela=… — this row going green as a panel setting is
|
|
||||||
# exactly the silent mis-route the quoted prefix exists to prevent.
|
|
||||||
printf '%s\n' 'panel=a b c' 'panela=b c' >"$TMP/glob-guard.conf"
|
|
||||||
check "panela= is still a malformed label row, never a panel setting (D7)" 1 \
|
|
||||||
"malformed label row" load_config "$TMP/glob-guard.conf"
|
|
||||||
printf '%s\n' 'panel[z]=b c' >"$TMP/bracket-only.conf"
|
|
||||||
check "a bracketed row does not satisfy the mandatory panel=" 1 \
|
|
||||||
"missing panel= line" load_config "$TMP/bracket-only.conf"
|
|
||||||
printf '%s\n' 'panel=a b c' 'panel[z]=b c' \
|
|
||||||
'scope:one|C5DEF5|First scope' >"$TMP/mixed.conf"
|
|
||||||
check "configured_label_rows returns the scope rows alone" 0 \
|
|
||||||
"scope:one|C5DEF5|First scope" configured_label_rows "$TMP/mixed.conf"
|
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
|
||||||
check "no panel[...] row reaches the bootstrap" 1 "" bash -c \
|
|
||||||
'source "$1"; configured_label_rows "$2" | grep -F "panel["' _ \
|
|
||||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/mixed.conf"
|
|
||||||
|
|
||||||
# LABELS.md is mirrored byte-identically into every governed repo, so any
|
# LABELS.md is mirrored byte-identically into every governed repo, so any
|
||||||
# scope enumeration it carries is true at home and false everywhere else —
|
# scope enumeration it carries is true at home and false everywhere else —
|
||||||
# 14 of 16 vendored rows were false across the family when this fired (#104).
|
# 14 of 16 vendored rows were false across the family when this fired (#104).
|
||||||
|
|
@ -154,16 +69,15 @@ check "LABELS.md enumerates no repo's scope labels" 1 "0" \
|
||||||
# same lists. review_requested/review_request_removed are the wake that
|
# same lists. review_requested/review_request_removed are the wake that
|
||||||
# clears blocker:unrequested — the label sat false for as long as a quiet
|
# clears blocker:unrequested — the label sat false for as long as a quiet
|
||||||
# repo stayed quiet because the one event that falsifies it was never
|
# repo stayed quiet because the one event that falsifies it was never
|
||||||
# listed (#137). The issues list narrowed to [opened, closed, edited, reopened]
|
# listed (#137). edited/reopened are the wakes for the two events that
|
||||||
# (#199): each carries a queue-state change the hourly cron cannot wait one
|
# falsify issue labels silently — an edited body rewrites the `Blocked by
|
||||||
# cadence for — opened drives mint→needs-triage, closed the blocker-closes→ready
|
# #N` declaration the reconcile sweep parses, and a reopened issue
|
||||||
# self-heal, edited a body rewrite of the `Blocked by #N` line the sweep parses,
|
# re-enters the queue wearing labels derived at close; PR #32 widened the
|
||||||
# reopened a closed issue re-entering the queue — while the churn/validation
|
# caller by both and the stub never followed (#144). The stub is prose, so
|
||||||
# actions (labeled/unlabeled/assigned/unassigned) came off. The stub is prose, so nothing but these rows
|
# nothing but these rows keeps the lists from drifting: a type in one file
|
||||||
# keeps the lists from drifting: a type in one file only is a wake that fires
|
# only is a wake that fires at home and nowhere in the fleet, or the
|
||||||
# at home and nowhere in the fleet, or the reverse — the drift #144 caught.
|
# reverse. The NF guard keeps `issues: write` under permissions: from
|
||||||
# The NF guard keeps `issues: write` under permissions: from matching the
|
# matching the issues: trigger key.
|
||||||
# issues: trigger key.
|
|
||||||
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
|
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
|
||||||
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
|
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
|
||||||
}
|
}
|
||||||
|
|
@ -174,20 +88,17 @@ types_in_sync() { # $1 = trigger key, $2 = caller, $3 = stub → 0 when both lis
|
||||||
}
|
}
|
||||||
CALLER="$ROOT/.github/workflows/self-labels.yml"
|
CALLER="$ROOT/.github/workflows/self-labels.yml"
|
||||||
STUB="$ROOT/docs/CONSUMERS.md"
|
STUB="$ROOT/docs/CONSUMERS.md"
|
||||||
# event_types anchors on the bare trigger key (NF == 1), so it reads the real
|
|
||||||
# types line even though the #199 comments name pull_request_target: and
|
|
||||||
# issues: in prose above the keys — an inline /pull_request_target:/ scan would
|
|
||||||
# latch onto the first mention and read the wrong list.
|
|
||||||
pr_has_both_review_wakes() {
|
|
||||||
event_types "$CALLER" pull_request_target | grep -F review_requested | grep -qF review_request_removed
|
|
||||||
}
|
|
||||||
check "caller and stub pull_request_target lists are identical" 0 "" \
|
check "caller and stub pull_request_target lists are identical" 0 "" \
|
||||||
types_in_sync pull_request_target "$CALLER" "$STUB"
|
types_in_sync pull_request_target "$CALLER" "$STUB"
|
||||||
check "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes
|
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||||
|
check "the caller lists both review-request wakes" 0 "" bash -c \
|
||||||
|
'awk "/pull_request_target:/{f=1; next} f && /types: /{print; exit}" "$1" |
|
||||||
|
grep -F review_requested | grep -qF review_request_removed' _ "$CALLER"
|
||||||
check "caller and stub issues lists are identical" 0 "" \
|
check "caller and stub issues lists are identical" 0 "" \
|
||||||
types_in_sync issues "$CALLER" "$STUB"
|
types_in_sync issues "$CALLER" "$STUB"
|
||||||
check "the caller lists exactly the queue-state-changing issue types" 0 \
|
check "the caller still lists all eight issue types" 0 \
|
||||||
"types: [opened, closed, edited, reopened]" event_types "$CALLER" issues
|
"types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]" \
|
||||||
|
event_types "$CALLER" issues
|
||||||
# the failing cases: drop a type from either file, or reorder one list only,
|
# the failing cases: drop a type from either file, or reorder one list only,
|
||||||
# and the identity rows above go red — exercised here on mutated copies
|
# and the identity rows above go red — exercised here on mutated copies
|
||||||
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
|
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
|
||||||
|
|
@ -201,62 +112,14 @@ sed 's/review_requested, review_request_removed/review_request_removed, review_r
|
||||||
"$STUB" >"$mut_stub"
|
"$STUB" >"$mut_stub"
|
||||||
check "a reorder in one list only goes red" 1 "" \
|
check "a reorder in one list only goes red" 1 "" \
|
||||||
types_in_sync pull_request_target "$CALLER" "$mut_stub"
|
types_in_sync pull_request_target "$CALLER" "$mut_stub"
|
||||||
sed 's/, closed//' "$CALLER" >"$mut_caller"
|
sed 's/, edited//' "$CALLER" >"$mut_caller"
|
||||||
check "an issue type dropped from the caller goes red" 1 "" \
|
check "an issue type dropped from the caller goes red" 1 "" \
|
||||||
types_in_sync issues "$mut_caller" "$STUB"
|
types_in_sync issues "$mut_caller" "$STUB"
|
||||||
sed 's/, closed//' "$STUB" >"$mut_stub"
|
sed 's/, edited//' "$STUB" >"$mut_stub"
|
||||||
check "an issue type dropped from the stub goes red" 1 "" \
|
check "an issue type dropped from the stub goes red" 1 "" \
|
||||||
types_in_sync issues "$CALLER" "$mut_stub"
|
types_in_sync issues "$CALLER" "$mut_stub"
|
||||||
sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
|
sed 's/closed, reopened/reopened, closed/' "$STUB" >"$mut_stub"
|
||||||
check "an issue-list reorder in one file only goes red" 1 "" \
|
check "an issue-list reorder in one file only goes red" 1 "" \
|
||||||
types_in_sync issues "$CALLER" "$mut_stub"
|
types_in_sync issues "$CALLER" "$mut_stub"
|
||||||
|
|
||||||
# --- #195: the conf's roster and CONTRIBUTING's roster table are one set ----
|
|
||||||
# The rot this catches: labels.conf named five identities, CONTRIBUTING named
|
|
||||||
# the same five, and none of the five existed on the forge — two files in
|
|
||||||
# perfect agreement with each other and none with reality. No offline check
|
|
||||||
# can reach the second half; what it can hold is that a roster edit touching
|
|
||||||
# one file and not the other goes red, which is the drift that turns a
|
|
||||||
# deliberate swap into a silent one.
|
|
||||||
|
|
||||||
# roster_from_conf <conf> — every identity panel= and triage-actors= name.
|
|
||||||
roster_from_conf() {
|
|
||||||
sed -nE 's/^(panel|triage-actors)=//p' "$1" | tr ' ' '\n' | sed '/^$/d' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
# roster_from_doc <contributing> — the identities the "### Roster" table's
|
|
||||||
# first column names. Anchored to the section rather than to the table's
|
|
||||||
# shape: another table elsewhere in the file must not be able to join the
|
|
||||||
# roster by looking like one.
|
|
||||||
# shellcheck disable=SC2016 # the backticks below are the table's, not a subshell
|
|
||||||
roster_from_doc() {
|
|
||||||
awk '/^### Roster$/ { inside = 1; next }
|
|
||||||
inside && /^#+ / { exit }
|
|
||||||
inside' "$1" |
|
|
||||||
sed -nE 's/^\| `([^`]+)`.*/\1/p' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
roster_in_sync() { # <conf> <contributing>
|
|
||||||
local conf="$1" doc="$2" drift
|
|
||||||
drift="$(diff <(roster_from_conf "$conf") <(roster_from_doc "$doc"))" && return 0
|
|
||||||
echo "roster drift ('<' conf only, '>' table only):" >&2
|
|
||||||
printf '%s\n' "$drift" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
CONF="$ROOT/.github/labels.conf"
|
|
||||||
CONTRIB="$ROOT/CONTRIBUTING.md"
|
|
||||||
check "the real conf and the real roster table name the same identities" 0 "" \
|
|
||||||
roster_in_sync "$CONF" "$CONTRIB"
|
|
||||||
# the failing cases, in both directions — a one-way check would have passed
|
|
||||||
# all week on the rot that produced #195
|
|
||||||
mut_conf="$TMP/mut-labels.conf" mut_contrib="$TMP/mut-contributing.md"
|
|
||||||
sed 's/^panel=/panel=ghost-bot /' "$CONF" >"$mut_conf"
|
|
||||||
check "an identity in the conf but not the table goes red" 1 "ghost-bot" \
|
|
||||||
roster_in_sync "$mut_conf" "$CONTRIB"
|
|
||||||
# shellcheck disable=SC2016 # the backticks are the table's, not a subshell
|
|
||||||
sed 's/^| `glm-bot-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib"
|
|
||||||
check "an identity in the table but not the conf goes red" 1 "ghost-bot" \
|
|
||||||
roster_in_sync "$CONF" "$mut_contrib"
|
|
||||||
|
|
||||||
summary
|
summary
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# The forge-portability guard (#198, enforcing #197's acceptance bar):
|
|
||||||
#
|
|
||||||
# No runtime `gh` invocation survives outside lib/forge-github.sh, except
|
|
||||||
# in a file that declares CEREMONY_FORGE_CLIENT=gh and therefore refuses
|
|
||||||
# loudly on a forge that cannot serve it.
|
|
||||||
#
|
|
||||||
# WHY THIS FILE EXISTS, rather than the rule living in review. #188 ported
|
|
||||||
# every `gh` call site onto the shim. The 0.6.0 upstream merge put SEVEN of
|
|
||||||
# them back — not in the eighteen conflict hunks, where a resolver would have
|
|
||||||
# been forced to look, but in whole functions upstream added to files this
|
|
||||||
# tree already owned. `git merge` takes upstream's side wherever only upstream
|
|
||||||
# moved a region, so it raised no conflict and asked no question. Reviewing
|
|
||||||
# the hunks could not have caught them; four reviewers reading the same diff
|
|
||||||
# each found a different subset.
|
|
||||||
#
|
|
||||||
# The sweep runs on a Forgejo instance whose runner image carries curl, jq and
|
|
||||||
# node and has NEITHER gh NOR stoke (lib/forge-forgejo.sh's header, probe task
|
|
||||||
# 278). So a reintroduced `gh` is not a style problem — it is `gh: command not
|
|
||||||
# found` mid-sweep, or a write that silently never happens.
|
|
||||||
#
|
|
||||||
# And it is invisible to the rest of the suite by construction: the contract
|
|
||||||
# tests stub `gh` as a shell function or on PATH, so they exercise a
|
|
||||||
# reintroduced call site happily and go green. This guard reads the SOURCE,
|
|
||||||
# which is the only place the difference is visible.
|
|
||||||
#
|
|
||||||
# It is deliberately a source-level check, and deliberately the ONLY one of
|
|
||||||
# its kind: every other guard here drives behaviour. This one cannot — the
|
|
||||||
# behaviour it forbids is unobservable in a harness that provides a `gh`.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
|
|
||||||
# The backend that is ALLOWED to speak gh — it is the whole point of the file.
|
|
||||||
ALLOWED_FILE='lib/forge-github.sh'
|
|
||||||
|
|
||||||
|
|
||||||
# A file may opt out by declaring the client it speaks, which makes
|
|
||||||
# forge_preflight refuse by name on a forge that cannot serve it. Today that
|
|
||||||
# is actions/refs-not-closing, whose only gather is GraphQL and which Forgejo
|
|
||||||
# therefore cannot run at all (#199 ports it and drops the declaration).
|
|
||||||
# Both spellings, because both surfaces must be able to declare: `=` for a
|
|
||||||
# shell script, `:` for a workflow's env block. A filename exemption was the
|
|
||||||
# first shape here and @codex-reviewer-andresmgsl was right to reject it —
|
|
||||||
# it exempts the whole FILE, so any later gh call anywhere in that workflow
|
|
||||||
# would ride in free, and it lets a declaration exist without a refusal.
|
|
||||||
declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT[=:][[:space:]]*gh[[:space:]]*$' "$1"; }
|
|
||||||
|
|
||||||
# Declaring is half of it. #197's bar is "declared AND refuses loudly", and the
|
|
||||||
# refusal has TWO halves that a single check conflates
|
|
||||||
# (@codex-reviewer-andresmgsl, #198):
|
|
||||||
#
|
|
||||||
# * the FORGE — a gh dispatch is wrong on a forge that cannot serve it, and
|
|
||||||
# asking only "is gh installed?" passes the moment a Forgejo runner image
|
|
||||||
# happens to ship gh, which is the client/forge mismatch forge_preflight
|
|
||||||
# exists to prevent;
|
|
||||||
# * the BINARY — present or not on this runner.
|
|
||||||
#
|
|
||||||
# forge_preflight answers both, so a script that calls it satisfies both. A
|
|
||||||
# workflow has no shell to call it from and must do both inline.
|
|
||||||
#
|
|
||||||
# Comments are stripped first, for the same reason gh_calls strips them and
|
|
||||||
# with the same lesson learned the hard way: the first version of this
|
|
||||||
# predicate was satisfied by the word `forge_preflight` inside labels.yml's own
|
|
||||||
# comment EXPLAINING that it has no forge_preflight to call. A guard that reads
|
|
||||||
# prose as evidence is the blind sweep again, and it passed its own mutation
|
|
||||||
# test because of it.
|
|
||||||
strip_comments() { sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1"; }
|
|
||||||
refuses_wrong_forge() {
|
|
||||||
strip_comments "$1" | grep -qE 'forge_preflight|GITHUB_SERVER_URL.*github\.com'
|
|
||||||
}
|
|
||||||
refuses_missing_binary() {
|
|
||||||
strip_comments "$1" | grep -qE 'forge_preflight|command -v gh'
|
|
||||||
}
|
|
||||||
refuses_when_unavailable() {
|
|
||||||
refuses_wrong_forge "$1" && refuses_missing_binary "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# A runtime invocation, not the word. `gh` must be at a command position and
|
|
||||||
# followed by a gh subcommand — and comment lines are stripped first, because
|
|
||||||
# these surfaces document at length what gh used to do here and a guard that
|
|
||||||
# went red on its own prose would be deleted within a week
|
|
||||||
# (@kimi-reviewer-andresmgsl, #198). Nothing here reads a comment as evidence.
|
|
||||||
gh_calls() { # $1 = file → "line:code" per runtime gh invocation
|
|
||||||
# Comments are BLANKED rather than dropped, so grep -n still reports the
|
|
||||||
# file's real line numbers. Trailing comments go too, not just whole-line
|
|
||||||
# ones: a workflow's `actions: write # ...gh workflow run...` is prose
|
|
||||||
# about a call site, and YAML puts it after the code rather than before it.
|
|
||||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" \
|
|
||||||
| grep -nE '(^|[^[:alnum:]_./$-])gh[[:space:]]+(api|issue|pr|release|repo|run|search|workflow|label|auth|browse|gist|secret|variable|ruleset)\b'
|
|
||||||
}
|
|
||||||
|
|
||||||
# The surfaces that run on a forge: executables and the workflows that call
|
|
||||||
# them. test/ is excluded on purpose — a test stubbing `gh` is the harness
|
|
||||||
# doing its job, and forbidding the string there would forbid the stubs that
|
|
||||||
# make the github backend testable at all.
|
|
||||||
scanned_files() {
|
|
||||||
local f
|
|
||||||
for f in "$ROOT"/lib/*.sh "$ROOT"/actions/*/*.sh "$ROOT"/bin/* \
|
|
||||||
"$ROOT"/.github/scripts/*.sh "$ROOT"/.github/workflows/*.yml; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
printf '%s\n' "${f#"$ROOT"/}"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
offenders() {
|
|
||||||
local rel abs
|
|
||||||
while IFS= read -r rel; do
|
|
||||||
[ "$rel" = "$ALLOWED_FILE" ] && continue
|
|
||||||
abs="$ROOT/$rel"
|
|
||||||
if declares_gh_client "$abs"; then
|
|
||||||
refuses_when_unavailable "$abs" && continue
|
|
||||||
printf '%s: declares CEREMONY_FORGE_CLIENT=gh but carries no refusal\n' "$rel"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
gh_calls "$abs" | sed "s|^|$rel:|"
|
|
||||||
done < <(scanned_files)
|
|
||||||
}
|
|
||||||
|
|
||||||
# In-process, not `bash -c`: a subshell cannot see these functions, so the
|
|
||||||
# sweep would find nothing, report empty, and pass by looking at nothing —
|
|
||||||
# the blind-sweep shape this guard exists to forbid, inside the guard itself.
|
|
||||||
no_offenders() {
|
|
||||||
local found
|
|
||||||
found="$(offenders)"
|
|
||||||
[ -z "$found" ] || { printf '%s\n' "$found" | sed 's/^/ /'; return 1; }
|
|
||||||
}
|
|
||||||
check "no runtime gh outside the github backend or a declared-client file" 0 "" \
|
|
||||||
no_offenders
|
|
||||||
|
|
||||||
# --- the guard has teeth ------------------------------------------------------
|
|
||||||
# A guard nobody has watched fail is a guard nobody is testing. These drive the
|
|
||||||
# predicates directly, because the sweep above is a property of the whole tree
|
|
||||||
# and cannot be made to fail without editing it.
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'gh api "repos/$REPO/issues/1"' >"$TMP/bad.sh"
|
|
||||||
check "a reintroduced gh api read is seen" 0 "gh api" gh_calls "$TMP/bad.sh"
|
|
||||||
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'run gh issue comment "$n" --body x' >"$TMP/bad2.sh"
|
|
||||||
check "a reintroduced gh issue write is seen, staged or not" 0 "gh issue" \
|
|
||||||
gh_calls "$TMP/bad2.sh"
|
|
||||||
|
|
||||||
# The exact shape the 0.6.0 merge reintroduced, indented inside a function.
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'f() {' \
|
|
||||||
' guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments"' '}' \
|
|
||||||
>"$TMP/bad3.sh"
|
|
||||||
check "...including one nested in a function behind guarded_read" 0 "gh api" \
|
|
||||||
gh_calls "$TMP/bad3.sh"
|
|
||||||
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' '# gh api used to live here (#188)' \
|
|
||||||
'# run gh issue comment — retired' >"$TMP/prose.sh"
|
|
||||||
check "prose about gh is not a call site" 1 "" gh_calls "$TMP/prose.sh"
|
|
||||||
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'forge_api "repos/$REPO/issues/1"' \
|
|
||||||
'echo "the gh client speaks /api/v3"' >"$TMP/good.sh"
|
|
||||||
check "the shim verb is not mistaken for a call site" 1 "" gh_calls "$TMP/good.sh"
|
|
||||||
|
|
||||||
# Neighbouring identifiers must not read as the binary: `gh_calls`, `$gh`,
|
|
||||||
# a path ending in /gh, and `regh api` are all not an invocation of gh.
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'gh_calls() { :; }' 'regh api foo' \
|
|
||||||
'echo "$gh api"' >"$TMP/lookalike.sh"
|
|
||||||
check "lookalike identifiers are not call sites" 1 "" gh_calls "$TMP/lookalike.sh"
|
|
||||||
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'export CEREMONY_FORGE_CLIENT=gh' \
|
|
||||||
'gh api graphql -f query=x' >"$TMP/declared.sh"
|
|
||||||
check "a declared-client file opts out" 0 "" declares_gh_client "$TMP/declared.sh"
|
|
||||||
# A workflow declares in YAML, not shell — both spellings must count, or the
|
|
||||||
# only surface that cannot call forge_preflight is also the only one that
|
|
||||||
# cannot declare.
|
|
||||||
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
|
||||||
' CEREMONY_FORGE_CLIENT: gh' ' run: gh workflow run x' \
|
|
||||||
>"$TMP/declared.yml"
|
|
||||||
check "...and so does a workflow declaring it in YAML" 0 "" \
|
|
||||||
declares_gh_client "$TMP/declared.yml"
|
|
||||||
# Declared is not enough: #197's bar is declared AND refuses loudly.
|
|
||||||
check "a declaration without a refusal is not enough" 1 "" \
|
|
||||||
refuses_when_unavailable "$TMP/declared.yml"
|
|
||||||
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
|
||||||
' CEREMONY_FORGE_CLIENT: gh' \
|
|
||||||
' run: |' \
|
|
||||||
' command -v gh >/dev/null || { echo "::warning::not woken"; exit 0; }' \
|
|
||||||
' gh workflow run x' >"$TMP/declared-refusing.yml"
|
|
||||||
# Binary presence ALONE is not a refusal: a Forgejo runner that ships gh would
|
|
||||||
# sail past it and dispatch against a forge that cannot serve the call.
|
|
||||||
check "...and a declaration guarded only by binary presence still is not" 1 "" \
|
|
||||||
refuses_when_unavailable "$TMP/declared-refusing.yml"
|
|
||||||
check "...though it does satisfy the binary half on its own" 0 "" \
|
|
||||||
refuses_missing_binary "$TMP/declared-refusing.yml"
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
|
||||||
' CEREMONY_FORGE_CLIENT: gh' \
|
|
||||||
' run: |' \
|
|
||||||
' [ "$GITHUB_SERVER_URL" = "https://github.com" ] || exit 0' \
|
|
||||||
' command -v gh >/dev/null || exit 0' \
|
|
||||||
' gh workflow run x' >"$TMP/declared-both.yml"
|
|
||||||
check "...and a declaration guarding BOTH forge and binary is" 0 "" \
|
|
||||||
refuses_when_unavailable "$TMP/declared-both.yml"
|
|
||||||
# labels.yml WAS the real customer for that pair. #205 ported its dispatch to
|
|
||||||
# REST, so it no longer speaks gh and must no longer declare a client — the
|
|
||||||
# exemption is spent, not inherited. Asserting its ABSENCE is what stops the
|
|
||||||
# declaration coming back as cover for a re-added `gh` call: an opt-out with no
|
|
||||||
# gh behind it is a standing permission slip.
|
|
||||||
check "labels.yml no longer declares a client, because it speaks none (#205)" 1 "" \
|
|
||||||
declares_gh_client "$ROOT/.github/workflows/labels.yml"
|
|
||||||
# shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must
|
|
||||||
# carry: the endpoint has to be built from the caller's input, not hardcoded.
|
|
||||||
labels_yml_dispatches_by_rest() {
|
|
||||||
grep -qF 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
|
|
||||||
"$ROOT/.github/workflows/labels.yml"
|
|
||||||
}
|
|
||||||
check "...and dispatches the sweep over REST instead" 0 "" labels_yml_dispatches_by_rest
|
|
||||||
check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh"
|
|
||||||
# A mention of the variable in prose is not a declaration.
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \
|
|
||||||
>"$TMP/mentions.sh"
|
|
||||||
check "...nor does prose mentioning the variable" 1 "" \
|
|
||||||
declares_gh_client "$TMP/mentions.sh"
|
|
||||||
|
|
||||||
# The scan must actually reach the surfaces it claims to, or it passes by
|
|
||||||
# looking at nothing — the blind-sweep shape this repo keeps filing issues
|
|
||||||
# about, in its own guard.
|
|
||||||
scan_is_wide() { [ "$(scanned_files | wc -l)" -ge 20 ]; }
|
|
||||||
check "the scan reaches every executable surface" 0 "" scan_is_wide
|
|
||||||
scan_lists_backend() { scanned_files | grep -F lib/forge-github.sh; }
|
|
||||||
check "...including the backend it exempts" 0 "lib/forge-github.sh" scan_lists_backend
|
|
||||||
check "...and the backend really does speak gh, so the exemption is load-bearing" 0 "gh api" \
|
|
||||||
gh_calls "$ROOT/lib/forge-github.sh"
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for lib/preflight.sh (issue #273) — every row of the
|
|
||||||
# merge-door resume table, offline. set -u, not -e: refusals 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"
|
|
||||||
|
|
||||||
PREFLIGHT="$ROOT/lib/preflight.sh"
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
VER=1.2.3
|
|
||||||
MERGE_SHA=1111111111111111111111111111111111111111
|
|
||||||
FOREIGN_SHA=2222222222222222222222222222222222222222
|
|
||||||
|
|
||||||
# preflight <VER> <MERGE_SHA> <TAG_SHAS> <RELEASED> — run the pure decision
|
|
||||||
# with exactly the four gathered facts in its environment.
|
|
||||||
preflight() {
|
|
||||||
# shellcheck disable=SC2016 # PREFLIGHT expands inside the isolated child
|
|
||||||
env VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" \
|
|
||||||
PREFLIGHT="$PREFLIGHT" bash -c '. "$PREFLIGHT"; release_preflight'
|
|
||||||
}
|
|
||||||
|
|
||||||
preflight_stdout() {
|
|
||||||
preflight "$@" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
preflight_stderr() {
|
|
||||||
{ preflight "$@" >/dev/null; } 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
refuses_without_output() {
|
|
||||||
local out rc
|
|
||||||
out="$(preflight "$@" 2>/dev/null)"
|
|
||||||
rc=$?
|
|
||||||
[ "$rc" -eq 1 ] && [ -z "$out" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the four table rows ----------------------------------------------------
|
|
||||||
|
|
||||||
check "row 1: a published release refuses even with no tag" 1 \
|
|
||||||
"release '$VER' already exists — this release already happened" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "" yes
|
|
||||||
check "row 1: a published release refuses with the matching tag" 1 \
|
|
||||||
"release '$VER' already exists — this release already happened" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
|
|
||||||
check "row 1: a published release refuses with a foreign tag" 1 \
|
|
||||||
"release '$VER' already exists — this release already happened" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" yes
|
|
||||||
check "row 1: refusal emits no workflow output" 0 "" \
|
|
||||||
refuses_without_output "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
|
|
||||||
|
|
||||||
check "row 2: an ordinary first run proceeds" 0 "resume=no" \
|
|
||||||
preflight_stdout "$VER" "$MERGE_SHA" "" no
|
|
||||||
|
|
||||||
check "row 3: the matching tag resumes" 0 "resume=yes" \
|
|
||||||
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
|
|
||||||
check "row 3: resume notice names the previous failed publish" 0 \
|
|
||||||
"a previous run of this door tagged and then failed to publish" \
|
|
||||||
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
|
|
||||||
check "row 3: an annotated tag resumes when the peeled ref matches" 0 \
|
|
||||||
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
|
|
||||||
"$FOREIGN_SHA"$'\n'"$MERGE_SHA" no
|
|
||||||
check "row 3: an annotated tag resumes when the direct ref matches" 0 \
|
|
||||||
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
|
|
||||||
"$MERGE_SHA"$'\n'"$FOREIGN_SHA" no
|
|
||||||
|
|
||||||
check "row 4: a foreign tag refuses" 1 "tag '$VER' already exists" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
|
||||||
check "row 4: the refusal names the foreign tag SHA" 1 "$FOREIGN_SHA" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
|
||||||
check "row 4: the refusal names the merge SHA" 1 "$MERGE_SHA" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
|
||||||
check "row 4: refusal emits no workflow output" 0 "" \
|
|
||||||
refuses_without_output "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
|
||||||
|
|
||||||
# A ref object that merely contains MERGE_SHA is not the merge commit. Each
|
|
||||||
# ls-remote object name is compared as a whole line.
|
|
||||||
PREFIX_SHA="${MERGE_SHA%?}"
|
|
||||||
check "a prefix of MERGE_SHA does not resume" 1 "already exists at $PREFIX_SHA" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "$PREFIX_SHA" no
|
|
||||||
check "a line containing MERGE_SHA does not resume" 1 \
|
|
||||||
"already exists at x${MERGE_SHA}y" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "x${MERGE_SHA}y" no
|
|
||||||
|
|
||||||
# --- fact validation --------------------------------------------------------
|
|
||||||
|
|
||||||
check "empty VER refuses" 1 "VER is empty" \
|
|
||||||
preflight_stderr "" "$MERGE_SHA" "" no
|
|
||||||
check "empty MERGE_SHA refuses" 1 "MERGE_SHA is empty" \
|
|
||||||
preflight_stderr "$VER" "" "" no
|
|
||||||
check "empty RELEASED refuses" 1 "RELEASED is empty" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "" ""
|
|
||||||
check "malformed RELEASED refuses" 1 "RELEASED='maybe' — expected yes or no" \
|
|
||||||
preflight_stderr "$VER" "$MERGE_SHA" "" maybe
|
|
||||||
|
|
||||||
# --- stream discipline and purity ------------------------------------------
|
|
||||||
|
|
||||||
notice_stays_on_stdout() {
|
|
||||||
local stdout stderr
|
|
||||||
stdout="$(preflight "$VER" "$MERGE_SHA" "$MERGE_SHA" no 2>"$TMP/preflight.err")"
|
|
||||||
stderr="$(cat "$TMP/preflight.err")"
|
|
||||||
[ -n "$stdout" ] && [ -z "$stderr" ]
|
|
||||||
}
|
|
||||||
refusal_stays_on_stderr() {
|
|
||||||
local stdout stderr rc
|
|
||||||
stdout="$(preflight "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no 2>"$TMP/preflight.err")"
|
|
||||||
rc=$?
|
|
||||||
stderr="$(cat "$TMP/preflight.err")"
|
|
||||||
[ "$rc" -eq 1 ] && [ -z "$stdout" ] && [ -n "$stderr" ]
|
|
||||||
}
|
|
||||||
no_tool_calls() {
|
|
||||||
! grep -v '^[[:space:]]*#' "$PREFLIGHT" | grep -Ewq 'git|gh|curl|wget'
|
|
||||||
}
|
|
||||||
check "resume notice and output stay on stdout" 0 "" notice_stays_on_stdout
|
|
||||||
check "refusal stays on stderr" 0 "" refusal_stays_on_stderr
|
|
||||||
check "preflight calls no git/gh/network tools" 0 "" no_tool_calls
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for actions/refs-not-closing (issue #218). Bodies and
|
|
||||||
# closing-reference sets are fixtures: no network and no pull request are
|
|
||||||
# involved. set -u, not -e: 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"
|
|
||||||
|
|
||||||
SCRIPT="$ROOT/actions/refs-not-closing/refs-not-closing.sh"
|
|
||||||
ACTION="$ROOT/actions/refs-not-closing/action.yml"
|
|
||||||
ENTRYPOINT="$ROOT/actions/refs-not-closing/run.sh"
|
|
||||||
WORKFLOW="$ROOT/.github/workflows/refs-guard.yml"
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
body() {
|
|
||||||
local name="$1"
|
|
||||||
shift
|
|
||||||
printf '%s\n' "$@" >"$TMP/$name.md"
|
|
||||||
}
|
|
||||||
|
|
||||||
guard() {
|
|
||||||
local name="$1"
|
|
||||||
shift
|
|
||||||
bash "$SCRIPT" "$TMP/$name.md" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
body ref-5 'Refs #5'
|
|
||||||
check "Refs target with empty closing set passes" 0 "no Refs target" guard ref-5
|
|
||||||
check "Refs target with itself closing fails" 1 "#5" guard ref-5 5
|
|
||||||
check "Refs target with another issue closing passes" 0 "no Refs target" guard ref-5 9
|
|
||||||
|
|
||||||
body ordinary 'Closes #5'
|
|
||||||
check "ordinary Closes PR remains green" 0 "no Refs target" guard ordinary 5
|
|
||||||
|
|
||||||
body mixed 'Refs #5' '' 'This PR legitimately Closes #9.'
|
|
||||||
check "Refs #5 plus Closes #9 remains green" 0 "no Refs target" guard mixed 9
|
|
||||||
|
|
||||||
body prose 'Refs #5' '' 'Triage closes #5 by hand after the live proof.'
|
|
||||||
check "closing prose for a Refs target fails" 1 "closes #5" guard prose 5
|
|
||||||
check "failure prints the surrounding sentence" 1 \
|
|
||||||
"sentence: Triage closes #5 by hand after the live proof" guard prose 5
|
|
||||||
check "failure offers number-first rewrite" 1 "#N is" guard prose 5
|
|
||||||
check "failure offers number-free rewrite" 1 "closes the issue" guard prose 5
|
|
||||||
|
|
||||||
body code-span 'Refs #5' '' "The body must not contain \`Closes #5\` anywhere."
|
|
||||||
check "backticked closing keyword is reported as the match" 1 \
|
|
||||||
"matched: Closes #5" guard code-span 5
|
|
||||||
check "backtick failure explains that code spans do not protect" 1 \
|
|
||||||
"Backticks do not protect" guard code-span 5
|
|
||||||
|
|
||||||
body adjacency 'Refs #5' '' 'Triage closes #9 and #5 after the proof.'
|
|
||||||
check "non-adjacent #5 does not join closing set #9" 0 "no Refs target" \
|
|
||||||
guard adjacency 9
|
|
||||||
|
|
||||||
body empty ''
|
|
||||||
check "empty body remains green" 0 "no Refs target" guard empty 5
|
|
||||||
|
|
||||||
body incidents-211 'Refs #209' 'Triage closes #209 by hand.'
|
|
||||||
check "#211 incident replays red" 1 "#209" guard incidents-211 209
|
|
||||||
body incidents-214 'Refs #212' 'Triage closes #212 and #209 on that evidence.'
|
|
||||||
check "#214 incident replays red" 1 "#212" guard incidents-214 212
|
|
||||||
body incidents-200 'Refs #199' "A later edit added \`Closes #199\`."
|
|
||||||
check "#200 incident replays red" 1 "#199" guard incidents-200 199
|
|
||||||
|
|
||||||
body multiple 'Refs #5 and Refs #7.' 'Triage closes #5 and fixes #7 by hand.'
|
|
||||||
check "failure names every intersecting issue" 1 \
|
|
||||||
"scheduled to close: #5 #7" guard multiple 5 7
|
|
||||||
|
|
||||||
body soft-wrap 'Refs #5' '' 'Triage closes' '#5 by hand after the live proof.'
|
|
||||||
check "soft-wrapped closing prose is reported as one sentence" 1 \
|
|
||||||
"sentence: Triage closes #5 by hand after the live proof" \
|
|
||||||
guard soft-wrap 5
|
|
||||||
|
|
||||||
body refs-colon 'Refs: #5' '' 'Triage closes #5 after proof.'
|
|
||||||
check "Refs colon form is protected" 1 "matched: closes #5" \
|
|
||||||
guard refs-colon 5
|
|
||||||
body refs-link 'Refs [#5](https://example.test/issues/5)' '' \
|
|
||||||
'Triage closes #5 after proof.'
|
|
||||||
check "linked Refs form is protected" 1 "matched: closes #5" \
|
|
||||||
guard refs-link 5
|
|
||||||
|
|
||||||
for number in 207 191 190 176 165 164; do
|
|
||||||
body "incident-$number" "Refs #$number"
|
|
||||||
check "#$number incident replays green" 0 "no Refs target" \
|
|
||||||
guard "incident-$number"
|
|
||||||
done
|
|
||||||
|
|
||||||
check "missing body is a loud failure" 1 "missing or unreadable" \
|
|
||||||
bash "$SCRIPT" "$TMP/missing.md"
|
|
||||||
check "invalid closing set is a loud failure" 1 "invalid closing issue" \
|
|
||||||
guard ref-5 nope
|
|
||||||
|
|
||||||
# The action owns the network boundary. #199 made that boundary REST through
|
|
||||||
# the shim, so it is driven here on BOTH backends with stubs at the transport
|
|
||||||
# — a fake `gh api` for the github backend, a fake `curl` for the forgejo one.
|
|
||||||
# Stubbing the shim itself would prove only that the test can stub the shim.
|
|
||||||
mkdir -p "$TMP/bin"
|
|
||||||
|
|
||||||
# The fixture, one PR, expressed once and served by both stubs. The body
|
|
||||||
# REFERENCES #5 while a commit CLOSES it: the contradiction this action
|
|
||||||
# exists to catch, and the case a body-only port would miss (#199).
|
|
||||||
PR_BODY_DEFAULT='Refs #5'
|
|
||||||
COMMIT_MSG_DEFAULT='Closes #5
|
|
||||||
|
|
||||||
body text'
|
|
||||||
|
|
||||||
cat >"$TMP/bin/gh" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -u
|
|
||||||
[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$FORGE_CALL_LOG"
|
|
||||||
endpoint=""; jqexpr=""
|
|
||||||
shift # `api`
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--paginate) ;;
|
|
||||||
--jq) jqexpr="$2"; shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
case "${FAKE_FORGE_MODE:-success}" in
|
|
||||||
body-fails) case "$endpoint" in */commits*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;;
|
|
||||||
commits-fails) case "$endpoint" in */commits*) echo "fake commit read failed" >&2; exit 42 ;; esac ;;
|
|
||||||
esac
|
|
||||||
case "$endpoint" in
|
|
||||||
*/commits*) out="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')" ;;
|
|
||||||
*) out="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;;
|
|
||||||
esac
|
|
||||||
if [ -n "$jqexpr" ]; then printf '%s' "$out" | jq -r "$jqexpr"; else printf '%s' "$out"; fi
|
|
||||||
EOF
|
|
||||||
chmod +x "$TMP/bin/gh"
|
|
||||||
|
|
||||||
# curl as the forgejo backend calls it: -D <headers> -o <body> <url>. The
|
|
||||||
# x-total-count header is what forge_api's completeness proof compares
|
|
||||||
# against, so `partial` here is a REAL short gather, not a flag the test
|
|
||||||
# invents (#188, #4699).
|
|
||||||
cat >"$TMP/bin/curl" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -u
|
|
||||||
hdr=""; body=""; url=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-D) hdr="$2"; shift 2 ;;
|
|
||||||
-o) body="$2"; shift 2 ;;
|
|
||||||
-H) shift 2 ;;
|
|
||||||
-sS|-s|-S) shift ;;
|
|
||||||
*) url="$1"; shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$url" >>"$FORGE_CALL_LOG"
|
|
||||||
case "${FAKE_FORGE_MODE:-success}" in
|
|
||||||
body-fails) case "$url" in *"/commits"*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;;
|
|
||||||
commits-fails) case "$url" in *"/commits"*) echo "fake commit read failed" >&2; exit 42 ;; esac ;;
|
|
||||||
esac
|
|
||||||
total=1
|
|
||||||
case "$url" in
|
|
||||||
*"/commits"*)
|
|
||||||
payload="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')"
|
|
||||||
# `partial`: declare 9, serve 1 and then nothing. Serving the same item on
|
|
||||||
# every page instead would NOT be a short gather — forge_api walks until a
|
|
||||||
# short page, so a stub that never runs out lets it collect exactly the
|
|
||||||
# declared total and pass honestly. The first draft of this stub did that
|
|
||||||
# and the case passed while proving nothing.
|
|
||||||
if [ "${FAKE_FORGE_MODE:-success}" = partial ]; then
|
|
||||||
total=9
|
|
||||||
grep -q 'page=1\b' <<<"$url" || payload='[]'
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*) payload="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;;
|
|
||||||
esac
|
|
||||||
printf 'HTTP/1.1 200 OK\r\nx-total-count: %s\r\n\r\n' "$total" >"$hdr"
|
|
||||||
printf '%s' "$payload" >"$body"
|
|
||||||
EOF
|
|
||||||
chmod +x "$TMP/bin/curl"
|
|
||||||
|
|
||||||
# boundary <forge> <mode> [body] [commit-message]
|
|
||||||
boundary() {
|
|
||||||
local forge="$1" mode="$2"
|
|
||||||
local body="${3-$PR_BODY_DEFAULT}" msg="${4-$COMMIT_MSG_DEFAULT}"
|
|
||||||
env PATH="$TMP/bin:$PATH" \
|
|
||||||
FAKE_FORGE_MODE="$mode" FAKE_PR_BODY="$body" FAKE_COMMIT_MSG="$msg" \
|
|
||||||
CEREMONY_FORGE="$forge" \
|
|
||||||
CEREMONY_FORGE_API="https://forge.example/api/v1" GH_TOKEN=tok \
|
|
||||||
REPO="heavy-duty/ceremony" GITHUB_REPOSITORY="heavy-duty/ceremony" \
|
|
||||||
PR_NUMBER=268 \
|
|
||||||
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
|
|
||||||
bash "$ENTRYPOINT"
|
|
||||||
}
|
|
||||||
|
|
||||||
# THE PORTABILITY CLAIM, driven rather than asserted: one fixture, both
|
|
||||||
# backends, same verdict. This is the acceptance criterion #199 states.
|
|
||||||
check "a Refs promise contradicted by a commit is caught — github backend" 1 \
|
|
||||||
"#5" boundary github success
|
|
||||||
check "...and identically on the forgejo backend" 1 \
|
|
||||||
"#5" boundary forgejo success
|
|
||||||
|
|
||||||
# The half a body-only port would miss: the keyword is ONLY in the commit.
|
|
||||||
check "a closing keyword only in a commit message is detected — github" 1 \
|
|
||||||
"#5" boundary github success 'Refs #5' 'Closes #5'
|
|
||||||
check "...and on forgejo" 1 \
|
|
||||||
"#5" boundary forgejo success 'Refs #5' 'Closes #5'
|
|
||||||
# ...and the same PR passes when nothing closes it, so the case above is
|
|
||||||
# detecting the keyword rather than just failing on every input.
|
|
||||||
check "a body that only references, with no closing keyword anywhere, passes" 0 \
|
|
||||||
"" boundary forgejo success 'Refs #5' 'plain commit subject'
|
|
||||||
|
|
||||||
# A failed read must never reach the parser: an empty body parses to an empty
|
|
||||||
# closing set, which is a PASSING verdict the action never earned.
|
|
||||||
check "a failed body read refuses, non-zero — github" 1 \
|
|
||||||
"refusing a verdict" boundary github body-fails
|
|
||||||
check "...and on forgejo" 1 "refusing a verdict" boundary forgejo body-fails
|
|
||||||
check "a failed commit read refuses, non-zero" 1 \
|
|
||||||
"refusing a partial verdict" boundary github commits-fails
|
|
||||||
|
|
||||||
# The `hasNextPage` refusal, relocated (#199 spec 3): a short paginated gather
|
|
||||||
# is the REST equivalent, and the forgejo backend's x-total-count proof is
|
|
||||||
# what catches it. This is the case that would silently pass if the port had
|
|
||||||
# dropped --paginate.
|
|
||||||
check "an incomplete commit read refuses a partial verdict" 1 \
|
|
||||||
"refusing a partial verdict" boundary forgejo partial
|
|
||||||
|
|
||||||
# Strip comments first. The entrypoint's prose NAMES gh, GraphQL and
|
|
||||||
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
|
|
||||||
# the explanation rather than the code — it passes with the call still there.
|
|
||||||
entrypoint_code() { sed 's/#.*//' "$ENTRYPOINT"; }
|
|
||||||
invokes_gh() { entrypoint_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
|
|
||||||
holds_graphql() { entrypoint_code | grep -qi 'graphql'; }
|
|
||||||
declares_client() { entrypoint_code | grep -q 'CEREMONY_FORGE_CLIENT'; }
|
|
||||||
check "the entrypoint invokes no gh (#199)" 1 "" invokes_gh
|
|
||||||
check "...and holds no GraphQL" 1 "" holds_graphql
|
|
||||||
check "...and declares no forge client, because it speaks the shim" 1 "" declares_client
|
|
||||||
# The caller must SCHEDULE it now. A portable action behind a github-only gate
|
|
||||||
# is a guard that passes by never running (@kimi-reviewer-andresmgsl, #198).
|
|
||||||
check "the caller no longer gates the job on the forge" 1 "" \
|
|
||||||
grep -F "github.server_url" "$ROOT/.github/workflows/refs-guard.yml"
|
|
||||||
|
|
||||||
check "composite delegates to the tested entrypoint" 0 "run.sh" \
|
|
||||||
grep -F "run: bash \"\$GITHUB_ACTION_PATH/run.sh\"" "$ACTION"
|
|
||||||
|
|
||||||
check "workflow wakes on body edits" 0 "types: [opened, edited, reopened, synchronize]" \
|
|
||||||
grep -F "types: [opened, edited, reopened, synchronize]" "$WORKFLOW"
|
|
||||||
check "workflow is pull_request-only" 1 "" \
|
|
||||||
grep -E '^ (push|pull_request_target|workflow_dispatch|schedule|issue_comment):' \
|
|
||||||
"$WORKFLOW"
|
|
||||||
check "workflow grants read-only pull request access" 0 "pull-requests: read" \
|
|
||||||
grep -F "pull-requests: read" "$WORKFLOW"
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -11,12 +11,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
. "$ROOT/test/harness.sh"
|
. "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are simply undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
|
|
||||||
FACTS="$ROOT/lib/facts.sh"
|
FACTS="$ROOT/lib/facts.sh"
|
||||||
DECIDE="$ROOT/lib/decide.sh"
|
DECIDE="$ROOT/lib/decide.sh"
|
||||||
|
|
@ -29,11 +23,7 @@ trap 'rm -rf "$TMP"' EXIT
|
||||||
mkdir -p "$TMP/stub"
|
mkdir -p "$TMP/stub"
|
||||||
cat >"$TMP/stub/gh" <<'EOF'
|
cat >"$TMP/stub/gh" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# The label read is now GET commits/{sha}/pulls, a JSON array (#191).
|
if [ "$1" = api ]; then echo true; exit 0; fi
|
||||||
if [ "$1" = api ]; then
|
|
||||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "gh stub: unexpected call: gh $*" >&2
|
echo "gh stub: unexpected call: gh $*" >&2
|
||||||
exit 97
|
exit 97
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -87,7 +77,6 @@ chain() {
|
||||||
(
|
(
|
||||||
cd "${3:-$TMP/repo}" || exit 1
|
cd "${3:-$TMP/repo}" || exit 1
|
||||||
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
|
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
|
||||||
CEREMONY_FORGE=github \
|
|
||||||
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
|
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
|
||||||
bash "$FACTS")" || exit 1
|
bash "$FACTS")" || exit 1
|
||||||
printf '%s\n' "$facts_out"
|
printf '%s\n' "$facts_out"
|
||||||
|
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for the release-door path manifest (issue #237). The list
|
|
||||||
# is evidence for skipping a live drill, so drift in either direction must
|
|
||||||
# fail before a release record can make an incomplete claim.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
|
|
||||||
PATH_SCRIPT="$ROOT/.github/scripts/release-path.sh"
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
library_refs() {
|
|
||||||
local sibling_refs=no
|
|
||||||
case "$1" in
|
|
||||||
*/lib/*.sh) sibling_refs=yes ;;
|
|
||||||
esac
|
|
||||||
awk -v sibling_refs="$sibling_refs" '
|
|
||||||
/^[[:space:]]*#/ { next }
|
|
||||||
{
|
|
||||||
line = $0
|
|
||||||
while (match(line, /lib\/[[:alnum:]_.-]+\.sh/)) {
|
|
||||||
print substr(line, RSTART, RLENGTH)
|
|
||||||
line = substr(line, RSTART + RLENGTH)
|
|
||||||
}
|
|
||||||
# Door libraries source siblings through their own BASH_SOURCE dirname,
|
|
||||||
# so the executable line ends in /name.sh without a literal lib/ (#237).
|
|
||||||
if (sibling_refs == "yes" && $0 ~ /^[[:space:]]*(\.|source)[[:space:]]/) {
|
|
||||||
line = $0
|
|
||||||
while (match(line, /\/[[:alnum:]_.-]+\.sh/)) {
|
|
||||||
print "lib" substr(line, RSTART, RLENGTH)
|
|
||||||
line = substr(line, RSTART + RLENGTH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# derive_path <tree> — print the workflow, bin/ when a bin command sources a
|
|
||||||
# door library, and the workflow's direct + transitive lib dependencies.
|
|
||||||
derive_path() {
|
|
||||||
local tree="$1" workflow
|
|
||||||
local pending seen=" " lib file refs ref bin_uses_lib=no
|
|
||||||
workflow="$tree/.github/workflows/release.yml"
|
|
||||||
|
|
||||||
printf '%s\n' .github/workflows/release.yml
|
|
||||||
pending="$(library_refs "$workflow" | sort -u)"
|
|
||||||
|
|
||||||
while [ -n "$pending" ]; do
|
|
||||||
lib="$(printf '%s\n' "$pending" | sed -n '1p')"
|
|
||||||
pending="$(printf '%s\n' "$pending" | sed '1d')"
|
|
||||||
case "$seen" in
|
|
||||||
*" $lib "*) continue ;;
|
|
||||||
esac
|
|
||||||
seen="$seen$lib "
|
|
||||||
printf '%s\n' "$lib"
|
|
||||||
file="$tree/$lib"
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
refs="$(library_refs "$file" | sort -u)"
|
|
||||||
if [ -n "$refs" ]; then
|
|
||||||
pending="$(printf '%s\n%s\n' "$pending" "$refs" | sed '/^$/d' | sort -u)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -d "$tree/bin" ]; then
|
|
||||||
for file in "$tree"/bin/*; do
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
refs="$(library_refs "$file")"
|
|
||||||
for ref in $refs; do
|
|
||||||
case "$seen" in
|
|
||||||
*" $ref "*) bin_uses_lib=yes ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
[ "$bin_uses_lib" = no ] || printf '%s\n' bin/
|
|
||||||
}
|
|
||||||
|
|
||||||
declared_path() {
|
|
||||||
bash "$1/.github/scripts/release-path.sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
path_check() {
|
|
||||||
local tree="$1" declared derived missing extra
|
|
||||||
declared="$(declared_path "$tree" | sort -u)"
|
|
||||||
derived="$(derive_path "$tree" | sort -u)"
|
|
||||||
missing="$(comm -13 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))"
|
|
||||||
extra="$(comm -23 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))"
|
|
||||||
if [ -n "$missing" ]; then
|
|
||||||
printf 'release-path: missing dependency: %s\n' "$missing" >&2
|
|
||||||
fi
|
|
||||||
if [ -n "$extra" ]; then
|
|
||||||
printf 'release-path: stale path: %s\n' "$extra" >&2
|
|
||||||
fi
|
|
||||||
[ -z "$missing" ] && [ -z "$extra" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
path_check_reports_only() {
|
|
||||||
local tree="$1" expected="$2" output rc
|
|
||||||
output="$(path_check "$tree" 2>&1)"
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 1 ] || [ "$output" != "$expected" ]; then
|
|
||||||
printf 'expected only: %s\ngot (exit %s): %s\n' \
|
|
||||||
"$expected" "$rc" "$output" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
readme_has_no_path_enumeration() {
|
|
||||||
local token found=no
|
|
||||||
for token in \
|
|
||||||
bin/ lib/version.sh lib/decide.sh lib/facts.sh lib/changelog.sh lib/forge.sh
|
|
||||||
do
|
|
||||||
if grep -qF "$token" "$ROOT/drills/README.md"; then
|
|
||||||
printf 'drill doctrine enumerates release path: %s\n' "$token" >&2
|
|
||||||
found=yes
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ "$found" = no ]
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture() {
|
|
||||||
local name="$1" tree
|
|
||||||
tree="$TMP/$name"
|
|
||||||
mkdir -p "$tree/.github/scripts" "$tree/.github/workflows" "$tree/lib" "$tree/bin"
|
|
||||||
cp "$PATH_SCRIPT" "$tree/.github/scripts/release-path.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n. "%s"\n' \
|
|
||||||
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/preflight.sh"
|
|
||||||
# facts.sh sources BOTH on this tree: version.sh, and the forge shim #191
|
|
||||||
# put on the doors' path so a Forgejo consumer can publish (#198). The
|
|
||||||
# synthetic tree mirrors the real one, or every fixture below reports
|
|
||||||
# lib/forge.sh stale instead of exercising the case it is about.
|
|
||||||
printf '#!/usr/bin/env bash\n# shellcheck source=lib/version.sh\n. "%s"\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
|
|
||||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/version.sh" \
|
|
||||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
|
|
||||||
>"$tree/lib/facts.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/forge.sh"
|
|
||||||
printf '%s\n' "$tree"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Exact output is the record author's copy-paste source.
|
|
||||||
check "manifest prints the specified ordered release path" 0 \
|
|
||||||
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/preflight.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
|
|
||||||
bash "$PATH_SCRIPT"
|
|
||||||
check "real workflow and transitive dependencies match the manifest" 0 "" \
|
|
||||||
path_check "$ROOT"
|
|
||||||
check "drill doctrine does not duplicate the executable release path" 0 "" \
|
|
||||||
readme_has_no_path_enumeration
|
|
||||||
|
|
||||||
# A door growing a dependency must name the missing path (#237 D7).
|
|
||||||
tree="$(fixture missing)"
|
|
||||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
|
||||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
|
||||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
|
||||||
"\$CEREMONY_DIR/lib/version.sh" "\$CEREMONY_DIR/lib/ruling.sh" \
|
|
||||||
>"$tree/.github/workflows/release.yml"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
|
||||||
check "a new workflow library fails with only its missing path" 0 "" \
|
|
||||||
path_check_reports_only "$tree" \
|
|
||||||
"release-path: missing dependency: lib/ruling.sh"
|
|
||||||
|
|
||||||
# A library growing a sibling dependency in the production idiom must also
|
|
||||||
# name the missing path; a literal lib/ marker in a comment is not evidence.
|
|
||||||
tree="$(fixture missing-transitive)"
|
|
||||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
|
||||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
|
||||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
|
||||||
>"$tree/.github/workflows/release.yml"
|
|
||||||
printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \
|
|
||||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \
|
|
||||||
>>"$tree/lib/facts.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
|
||||||
check "a new sibling library fails with only its missing path" 0 "" \
|
|
||||||
path_check_reports_only "$tree" \
|
|
||||||
"release-path: missing dependency: lib/ruling.sh"
|
|
||||||
|
|
||||||
# A manifest may not rot into a safe-looking superset.
|
|
||||||
tree="$(fixture extra)"
|
|
||||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
|
||||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
|
||||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
|
||||||
>"$tree/.github/workflows/release.yml"
|
|
||||||
sed -i '$ s|$| \\|' \
|
|
||||||
"$tree/.github/scripts/release-path.sh"
|
|
||||||
printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh"
|
|
||||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
|
||||||
check "a path no door reads fails with only its stale path" 0 "" \
|
|
||||||
path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh"
|
|
||||||
|
|
||||||
# Transitive sourcing is part of the derivation, not decoration.
|
|
||||||
tree="$(fixture transitive)"
|
|
||||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
|
||||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
|
||||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
|
||||||
>"$tree/.github/workflows/release.yml"
|
|
||||||
# Only the version source is dropped; the forge source #191 added stays, or
|
|
||||||
# the fixture reports two stale paths and proves neither of them (#198).
|
|
||||||
printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
|
|
||||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
|
|
||||||
>"$tree/lib/facts.sh"
|
|
||||||
check "removing facts' version source fails with only its stale path" 0 "" \
|
|
||||||
path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -4,12 +4,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
source "$ROOT/test/harness.sh"
|
source "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
source "$ROOT/lib/ruling.sh"
|
source "$ROOT/lib/ruling.sh"
|
||||||
|
|
||||||
|
|
@ -115,71 +109,6 @@ check "labels only mid-sentence are malformed — line-anchoring is the rule" 0
|
||||||
check "an empty body is missing everything" 0 "MALFORMED Options: Recommend: Blocked: Default:" \
|
check "an empty body is missing everything" 0 "MALFORMED Options: Recommend: Blocked: Default:" \
|
||||||
ruling_shape_decision </dev/null
|
ruling_shape_decision </dev/null
|
||||||
|
|
||||||
# -- escalation selection: best-shaped wins, earliest breaks ties (#226) ----
|
|
||||||
# The crew#293 incident: a whole-round reply and the escalation land seconds
|
|
||||||
# apart inside one window, the reply earlier. Earliest-wins graded the reply.
|
|
||||||
# b64 here mirrors jq's @base64 — unwrapped, or the TSV rows would split.
|
|
||||||
|
|
||||||
b64enc() { printf '%s' "$1" | base64 | tr -d '\n'; }
|
|
||||||
ROUND_REPLY=$'🔧 addressing round on head 86c35f14 — every point answered below'
|
|
||||||
PARTIAL=$'Options: A — x B — y\nBlocked: z'
|
|
||||||
|
|
||||||
replay="$(printf 'setter %s https://x/reply %s\nsetter %s https://x/escalation %s\n' \
|
|
||||||
"$((L - 40))" "$(b64enc "$ROUND_REPLY")" "$((L - 7))" "$(b64enc "$TPL")")"
|
|
||||||
check "crew#293 replay: the complete escalation is selected over the earlier round reply" 0 \
|
|
||||||
"https://x/escalation $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$replay"
|
|
||||||
sel="$(ruling_escalation_row setter "$L" <<<"$replay")"
|
|
||||||
check "crew#293 replay: the selected body grades SHAPED" 0 "SHAPED" \
|
|
||||||
ruling_shape_decision <<<"$(base64 -d <<<"${sel#* }")"
|
|
||||||
check "the nudge's link follows the same selection" 0 "https://x/escalation" \
|
|
||||||
ruling_escalation_url setter "$L" <<<"$replay"
|
|
||||||
check "the rung wording reads Default: from the selected body" 0 "DEADLINE 2026-07-23T21:00Z" \
|
|
||||||
ruling_default_decision <<<"$(base64 -d <<<"${sel#* }")"
|
|
||||||
|
|
||||||
check "escalation-then-follow-up still selects the escalation" 0 \
|
|
||||||
"https://x/escalation $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/escalation %s\nsetter %s https://x/followup %s\n' \
|
|
||||||
"$((L - 300))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc 'thanks — clarified above')")"
|
|
||||||
|
|
||||||
check "a complete escalation beats an earlier partial" 0 \
|
|
||||||
"https://x/complete $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/partial %s\nsetter %s https://x/complete %s\n' \
|
|
||||||
"$((L - 300))" "$(b64enc "$PARTIAL")" "$((L - 60))" "$(b64enc "$TPL")")"
|
|
||||||
check "a complete escalation beats a later partial" 0 \
|
|
||||||
"https://x/complete $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/complete %s\nsetter %s https://x/partial %s\n' \
|
|
||||||
"$((L - 300))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc "$PARTIAL")")"
|
|
||||||
|
|
||||||
check "equal full scores break to the earliest" 0 \
|
|
||||||
"https://x/one $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/one %s\nsetter %s https://x/two %s\n' \
|
|
||||||
"$((L - 300))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc "$TPL_BOLD")")"
|
|
||||||
check "all-zero scores still break to the earliest" 0 "https://x/first" \
|
|
||||||
ruling_escalation_url setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/first\nsetter %s https://x/second\n' \
|
|
||||||
"$((L - 300))" "$((L - 60))")"
|
|
||||||
|
|
||||||
check "an out-of-window all-four row is never selected" 0 \
|
|
||||||
"https://x/in $(b64enc "$PARTIAL")" ruling_escalation_row setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/out %s\nsetter %s https://x/in %s\n' \
|
|
||||||
"$((L - 5000))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc "$PARTIAL")")"
|
|
||||||
check "an out-of-window all-four row cannot turn an empty result non-empty" 0 "" \
|
|
||||||
ruling_escalation_row setter "$L" <<<"setter $((L - 5000)) https://x/out $(b64enc "$TPL")"
|
|
||||||
check "another actor's all-four row is never selected" 0 "" \
|
|
||||||
ruling_escalation_row setter "$L" <<<"bystander $((L - 60)) https://x/other $(b64enc "$TPL")"
|
|
||||||
|
|
||||||
check "a garbage body column scores 0 and never errors" 0 \
|
|
||||||
"https://x/good $(b64enc "$PARTIAL")" ruling_escalation_row setter "$L" <<<"$(
|
|
||||||
printf 'setter %s https://x/garbage !!!not-base64!!!\nsetter %s https://x/good %s\n' \
|
|
||||||
"$((L - 300))" "$((L - 60))" "$(b64enc "$PARTIAL")")"
|
|
||||||
check "a garbage-only candidate is still a legal selection" 0 \
|
|
||||||
"https://x/garbage !!!not-base64!!!" \
|
|
||||||
ruling_escalation_row setter "$L" <<<"setter $((L - 300)) https://x/garbage !!!not-base64!!!"
|
|
||||||
|
|
||||||
# shellcheck disable=SC2016 # the literal $field is the assertion — one spelling, unexpanded
|
|
||||||
check "the field matcher has exactly one spelling in lib/ruling.sh" 0 "1" \
|
|
||||||
grep -cF '(\*\*)?$field' "$ROOT/lib/ruling.sh"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The orchestrator, against a recording gh stub. The stub serves fixture JSON
|
# The orchestrator, against a recording gh stub. The stub serves fixture JSON
|
||||||
# per endpoint (missing file = unreadable read), applies the caller's --jq
|
# per endpoint (missing file = unreadable read), applies the caller's --jq
|
||||||
|
|
@ -194,7 +123,6 @@ run() { "$@"; }
|
||||||
|
|
||||||
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -207,7 +135,6 @@ gh() {
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
endpoint="$(forge_stub_path "$endpoint")"
|
|
||||||
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
[ -f "$file" ] || return 1
|
[ -f "$file" ] || return 1
|
||||||
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
|
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
|
||||||
|
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# The delta-inventory guard (#200, enforcing #197 D3's standing constraint).
|
|
||||||
#
|
|
||||||
# Forge-specific behaviour lives in the files docs/UPSTREAM-SYNC.md names,
|
|
||||||
# and nowhere else.
|
|
||||||
#
|
|
||||||
# WHY. This tree carries upstream's content plus a forge delta, forever — the
|
|
||||||
# sync is recurring and upstream is read-only (#197 D3). What makes that
|
|
||||||
# tractable is not luck: the 0.6.0 sync cost 18 conflict hunks across 10 files
|
|
||||||
# because the delta sits in six files upstream never touches. Scatter
|
|
||||||
# `forge_detect` into a seventh and every future sync pays for it, in a place
|
|
||||||
# nobody chose.
|
|
||||||
#
|
|
||||||
# So this fails on the PR that scatters it, rather than on the sync after next
|
|
||||||
# — the same reason changelog-monotonic guards a property no single tree can
|
|
||||||
# be asked about.
|
|
||||||
#
|
|
||||||
# IT RUNS OFFLINE, AND IT DOES NOT PASS BY ABSENCE. Tests must not need the
|
|
||||||
# network, and a guard that goes green when it cannot see its input is the
|
|
||||||
# blind-sweep shape this repo keeps writing issues about. So the inventory is
|
|
||||||
# read from the document and the tree is scanned for forge markers — both local
|
|
||||||
# — and a missing or empty `.upstream-ref` is a REFUSAL, not a skip.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
|
|
||||||
DOC="$ROOT/docs/UPSTREAM-SYNC.md"
|
|
||||||
REF="$ROOT/.upstream-ref"
|
|
||||||
|
|
||||||
# --- the recorded upstream ref ------------------------------------------------
|
|
||||||
|
|
||||||
check "the sync document exists" 0 "" test -f "$DOC"
|
|
||||||
check "the upstream ref is recorded" 0 "" test -f "$REF"
|
|
||||||
|
|
||||||
# THE OBJECT IS MANDATORY. An earlier head accepted "UNVERIFIABLE-HERE" when
|
|
||||||
# upstream's commit was not in the local store, which contradicted this issue's
|
|
||||||
# own "must not pass by absence" and made the check decorative in exactly the
|
|
||||||
# environment it matters in (@codex-reviewer-andresmgsl, #200 review).
|
|
||||||
#
|
|
||||||
# "Runs offline" means the TEST reads local evidence, not that CI may omit the
|
|
||||||
# evidence and pass. ci.yml fetches the recorded object before running the
|
|
||||||
# suite; if it is missing here, that is a refusal.
|
|
||||||
recorded_ref() { grep -vE '^[[:space:]]*(#|$)' "$REF" | head -n1; }
|
|
||||||
ref_is_recorded() { [ -n "$(recorded_ref)" ]; }
|
|
||||||
check "...and is not blank — an unrecorded ref is a refusal, not a skip" 0 "" \
|
|
||||||
ref_is_recorded
|
|
||||||
# Full 40, not a short prefix: a short SHA is ambiguous across a growing repo
|
|
||||||
# and cannot be compared byte-wise between the runbook, the CHANGELOG and here.
|
|
||||||
ref_is_full_sha() { printf '%s' "$(recorded_ref)" | grep -qE '^[0-9a-f]{40}$'; }
|
|
||||||
check "...and is a FULL 40-character SHA" 0 "" ref_is_full_sha
|
|
||||||
|
|
||||||
ref_object_present() { git -C "$ROOT" cat-file -e "$(recorded_ref)^{commit}" 2>/dev/null; }
|
|
||||||
check "the recorded commit is present locally — absent is a refusal, not a skip" 0 "" \
|
|
||||||
ref_object_present
|
|
||||||
ref_is_ancestor() { git -C "$ROOT" merge-base --is-ancestor "$(recorded_ref)" HEAD 2>/dev/null; }
|
|
||||||
check "...and is an ancestor of HEAD, so main really carries what is recorded" 0 "" \
|
|
||||||
ref_is_ancestor
|
|
||||||
|
|
||||||
# --- the inventory ------------------------------------------------------------
|
|
||||||
|
|
||||||
# The document's inventory table: the first `code` span of each table row in
|
|
||||||
# the "Where the forge delta lives" section.
|
|
||||||
inventory() {
|
|
||||||
awk '/^## Where the forge delta lives/ { on = 1; next }
|
|
||||||
on && /^## / { on = 0 }
|
|
||||||
on && /^\| `/ { gsub(/^\| `/, ""); sub(/`.*$/, ""); print }' "$DOC"
|
|
||||||
}
|
|
||||||
|
|
||||||
inventory_size() { inventory | wc -l; }
|
|
||||||
inventory_is_populated() { [ "$(inventory_size)" -ge 6 ]; }
|
|
||||||
check "the inventory names at least the six known delta files" 0 "" \
|
|
||||||
inventory_is_populated
|
|
||||||
|
|
||||||
# Exact for a file entry, `dir/` + one path separator for a directory entry.
|
|
||||||
# Prefix matching accepted `drills-old/x` for `drills/` and `lib/forge.sh.backup`
|
|
||||||
# for `lib/forge.sh` (@codex-reviewer-andresmgsl, #200 review).
|
|
||||||
in_inventory() { # $1 = repo-relative path
|
|
||||||
local entry
|
|
||||||
while IFS= read -r entry; do
|
|
||||||
[ -n "$entry" ] || continue
|
|
||||||
case "$entry" in
|
|
||||||
*/) case "$1" in "$entry"*) return 0 ;; esac ;;
|
|
||||||
*) [ "$1" = "$entry" ] && return 0 ;;
|
|
||||||
esac
|
|
||||||
done < <(inventory)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
check "a listed file is recognised" 0 "" in_inventory lib/forge.sh
|
|
||||||
check "...including one listed as a directory" 0 "" in_inventory drills/0.4.1.md
|
|
||||||
check "an unlisted file is not" 1 "" in_inventory lib/version.sh
|
|
||||||
|
|
||||||
# --- the scan -----------------------------------------------------------------
|
|
||||||
|
|
||||||
# What "forge-specific" means, mechanically: the selector's own verbs, the
|
|
||||||
# environment override that drives them, and — on a workflow, which has no
|
|
||||||
# shell to call the selector from — a forge decision written inline.
|
|
||||||
#
|
|
||||||
# The earlier version scanned only shell under lib/ actions/ bin/ .github/scripts.
|
|
||||||
# That missed three categories the inventory itself claims to govern: workflows,
|
|
||||||
# .github/labels.conf and drills/. Merged main already had blind spots there —
|
|
||||||
# labels.yml decides on GITHUB_SERVER_URL and declares a client, refs-guard.yml
|
|
||||||
# carries a positive GitHub-only scheduling decision
|
|
||||||
# (@codex-reviewer-andresmgsl, #200 review).
|
|
||||||
FORGE_MARKERS='forge_detect|forge_select|forge_preflight|forge_client|CEREMONY_FORGE\b|CEREMONY_FORGE_CLIENT|GITHUB_SERVER_URL|github\.server_url'
|
|
||||||
|
|
||||||
# SCAN_ROOT is a parameter so the teeth below can drive the REAL check against
|
|
||||||
# a constructed tree. The previous mutation proved the predicates and never
|
|
||||||
# invoked no_unlisted — the guard could have been `return 0` and still passed.
|
|
||||||
scan_root() { printf '%s' "${SCAN_ROOT:-$ROOT}"; }
|
|
||||||
|
|
||||||
# DISCOVERY IS DERIVED FROM THE TREE, not from a list of directories, depths
|
|
||||||
# and extensions. The earlier version hand-picked five globs and therefore
|
|
||||||
# could not see `actions/*/action.yml` — this repository's normal composite
|
|
||||||
# structure, where a client declaration is exactly a forge decision — or a
|
|
||||||
# workflow written `.yaml` rather than `.yml`. @codex-reviewer-andresmgsl
|
|
||||||
# constructed both and the guard stayed 21/21 green, which is the whole
|
|
||||||
# argument against maintaining a glob list.
|
|
||||||
#
|
|
||||||
# So: walk everything, then EXCLUDE by class, and let content classify the
|
|
||||||
# rest. Excluding is safer than including because a new file type arrives
|
|
||||||
# scanned rather than invisible.
|
|
||||||
#
|
|
||||||
# test/ the harness stubs and asserts these tokens by design
|
|
||||||
# changelog.d/ prose fragments
|
|
||||||
# *.md prose. `drills/` stays in the INVENTORY because its records
|
|
||||||
# are forge-specific by content, but a record mentioning a
|
|
||||||
# selector verb in prose is not a decision, and scanning prose
|
|
||||||
# for decisions is the mistake this guard's own comment
|
|
||||||
# handling exists to avoid.
|
|
||||||
#
|
|
||||||
# DISCOVERY IS GIT'S, NOT THE FILESYSTEM'S. An earlier head said "tracked" and
|
|
||||||
# used `find`, which walks the working directory and knows nothing about the
|
|
||||||
# index. That is not pedantry: `ci.yml` extracts shellcheck and actionlint
|
|
||||||
# tarballs INTO the checkout before the suite runs, and any developer cache
|
|
||||||
# sits there too. @codex-reviewer-andresmgsl reproduced a false red with one
|
|
||||||
# untracked file. `git ls-files -z` makes "tracked" executable rather than
|
|
||||||
# prose.
|
|
||||||
scanned_paths() {
|
|
||||||
local root; root="$(scan_root)"
|
|
||||||
git -C "$root" ls-files -z 2>/dev/null \
|
|
||||||
| tr '\0' '\n' \
|
|
||||||
| grep -vE '^(test/|changelog\.d/)' \
|
|
||||||
| grep -vE '\.md$' \
|
|
||||||
| sort
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_specific_files() {
|
|
||||||
local root rel; root="$(scan_root)"
|
|
||||||
while IFS= read -r rel; do
|
|
||||||
[ -n "$rel" ] || continue
|
|
||||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$root/$rel" 2>/dev/null \
|
|
||||||
| grep -qE "$FORGE_MARKERS" && printf '%s\n' "$rel"
|
|
||||||
done < <(scanned_paths)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# A file that merely CALLS the shim is not where the forge is decided — every
|
|
||||||
# reconciler calls forge_preflight and that is the point of the shim. A file
|
|
||||||
# that DECLARES a client is a different thing and is NOT exempt: it is a
|
|
||||||
# deliberate forge-delta location and belongs in the inventory
|
|
||||||
# (@codex-reviewer-andresmgsl).
|
|
||||||
SHIM_CONSUMERS='actions/issueflow-reconcile/issueflow-reconcile.sh
|
|
||||||
actions/labels-reconcile/labels-reconcile.sh
|
|
||||||
actions/labels-scope/labels-scope.sh
|
|
||||||
lib/facts.sh
|
|
||||||
lib/ruling.sh
|
|
||||||
lib/attention.sh
|
|
||||||
.github/workflows/release.yml'
|
|
||||||
|
|
||||||
declares_a_client() {
|
|
||||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$(scan_root)/$1" \
|
|
||||||
| grep -qE 'CEREMONY_FORGE_CLIENT[=:]'
|
|
||||||
}
|
|
||||||
|
|
||||||
unlisted_deciders() {
|
|
||||||
local rel
|
|
||||||
while IFS= read -r rel; do
|
|
||||||
[ -n "$rel" ] || continue
|
|
||||||
if grep -qxF "$rel" <<<"$SHIM_CONSUMERS" && ! declares_a_client "$rel"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
in_inventory "$rel" && continue
|
|
||||||
printf '%s\n' "$rel"
|
|
||||||
done < <(forge_specific_files)
|
|
||||||
}
|
|
||||||
|
|
||||||
no_unlisted() {
|
|
||||||
local found
|
|
||||||
found="$(unlisted_deciders)"
|
|
||||||
[ -z "$found" ] || {
|
|
||||||
printf 'forge-specific but not in docs/UPSTREAM-SYNC.md:\n' >&2
|
|
||||||
printf ' %s\n' "$found" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check "every forge-deciding file is named in the inventory" 0 "" no_unlisted
|
|
||||||
|
|
||||||
# --- teeth --------------------------------------------------------------------
|
|
||||||
# These drive the REAL top-level check against a constructed tree, via
|
|
||||||
# SCAN_ROOT. The earlier version asserted the predicates separately and never
|
|
||||||
# invoked no_unlisted — so the guard could have been replaced with `return 0`
|
|
||||||
# and both "must-fail" rows would still have passed
|
|
||||||
# (@codex-reviewer-andresmgsl, #200 review). A mutation test that cannot fail
|
|
||||||
# when the thing it guards is deleted is the shape this repo keeps filing
|
|
||||||
# issues about.
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
# A tiny GIT repository, because discovery is git's now: a fixture that is only
|
|
||||||
# a directory would be invisible to `ls-files` and every must-fail below would
|
|
||||||
# pass vacuously.
|
|
||||||
fixture_tree() { # -> a tracked tree with no forge-deciding files
|
|
||||||
local t="$TMP/tree"
|
|
||||||
rm -rf "$t"
|
|
||||||
mkdir -p "$t/lib" "$t/.github/workflows"
|
|
||||||
printf '#!/usr/bin/env bash\necho hello\n' >"$t/lib/plain.sh"
|
|
||||||
printf 'name: ci\non: [push]\n' >"$t/.github/workflows/plain.yml"
|
|
||||||
git -C "$t" init -q 2>/dev/null
|
|
||||||
git -C "$t" add -A 2>/dev/null
|
|
||||||
printf '%s' "$t"
|
|
||||||
}
|
|
||||||
track() { git -C "$root" add -A 2>/dev/null; }
|
|
||||||
|
|
||||||
root="$(fixture_tree)"
|
|
||||||
clean_tree_passes() { SCAN_ROOT="$root" no_unlisted; }
|
|
||||||
check "the guard is green on a tree with no forge decisions" 0 "" clean_tree_passes
|
|
||||||
|
|
||||||
# MUST FAIL: a shell file that decides the forge, in no inventory entry.
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'case "$(forge_detect)" in forgejo) : ;; esac' \
|
|
||||||
>"$root/lib/scattered.sh"
|
|
||||||
track; scattered_shell() { SCAN_ROOT="$root" no_unlisted; }
|
|
||||||
check "a new SHELL file deciding the forge fails the real guard" 1 "" scattered_shell
|
|
||||||
check "...and the failure names the file" 1 "lib/scattered.sh" scattered_shell
|
|
||||||
rm -f "$root/lib/scattered.sh"; track
|
|
||||||
|
|
||||||
# MUST FAIL on a NON-SHELL surface too, so coverage cannot regress to the old
|
|
||||||
# shell-only glob: a workflow deciding on the server URL.
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' ' steps:' \
|
|
||||||
' - run: [ "$GITHUB_SERVER_URL" = https://github.com ] || exit 0' \
|
|
||||||
>"$root/.github/workflows/scattered.yml"
|
|
||||||
track; scattered_workflow() { SCAN_ROOT="$root" no_unlisted; }
|
|
||||||
check "a new WORKFLOW deciding the forge fails it too" 1 "" scattered_workflow
|
|
||||||
check "...naming that file" 1 ".github/workflows/scattered.yml" scattered_workflow
|
|
||||||
rm -f "$root/.github/workflows/scattered.yml"; track
|
|
||||||
|
|
||||||
# @codex-reviewer-andresmgsl's two reproductions, verbatim as fixtures. Both
|
|
||||||
# passed the hand-picked-glob version 21/21, which is why discovery is derived
|
|
||||||
# from the tree now. Deleting a discovery class must make these red.
|
|
||||||
mkdir -p "$root/actions/unlisted-forge-decision"
|
|
||||||
printf '%s\n' 'name: x' 'runs:' ' using: composite' ' steps:' \
|
|
||||||
' - shell: bash' ' env:' ' CEREMONY_FORGE_CLIENT: gh' \
|
|
||||||
' run: true' >"$root/actions/unlisted-forge-decision/action.yml"
|
|
||||||
track; composite_action_seen() { SCAN_ROOT="$root" no_unlisted; }
|
|
||||||
check "a forge declaration in actions/*/action.yml fails the guard" 1 \
|
|
||||||
"actions/unlisted-forge-decision/action.yml" composite_action_seen
|
|
||||||
rm -rf "$root/actions/unlisted-forge-decision"; track
|
|
||||||
|
|
||||||
# ...and a workflow written .yaml rather than .yml — `*.yml` was never a
|
|
||||||
# complete workflow surface.
|
|
||||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
||||||
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' \
|
|
||||||
" if: github.server_url == 'https://github.com'" ' steps: []' \
|
|
||||||
>"$root/.github/workflows/unlisted-forge-decision.yaml"
|
|
||||||
track; yaml_workflow_seen() { SCAN_ROOT="$root" no_unlisted; }
|
|
||||||
check "...and one in a .yaml workflow does too" 1 \
|
|
||||||
".github/workflows/unlisted-forge-decision.yaml" yaml_workflow_seen
|
|
||||||
rm -f "$root/.github/workflows/unlisted-forge-decision.yaml"; track
|
|
||||||
check "...leaving the fixture tree green again" 0 "" clean_tree_passes
|
|
||||||
|
|
||||||
# A declaration is a delta location even in a file that would otherwise read as
|
|
||||||
# a shim consumer, so the consumer allow-list cannot hide one.
|
|
||||||
mkdir -p "$root/lib"
|
|
||||||
printf '%s\n' '#!/usr/bin/env bash' 'CEREMONY_FORGE_CLIENT=gh' >"$root/lib/facts.sh"
|
|
||||||
track; declared_not_exempt() { SCAN_ROOT="$root" no_unlisted; }
|
|
||||||
check "a shim consumer that DECLARES a client is not exempt" 1 "lib/facts.sh" \
|
|
||||||
declared_not_exempt
|
|
||||||
rm -f "$root/lib/facts.sh"; track
|
|
||||||
track
|
|
||||||
check "...and the tree is green again once it is gone" 0 "" clean_tree_passes
|
|
||||||
|
|
||||||
# UNTRACKED input is not source. ci.yml extracts shellcheck and actionlint
|
|
||||||
# tarballs into the checkout before the suite runs, and a developer cache sits
|
|
||||||
# there too; parsing either is a false red on something outside the repository
|
|
||||||
# property (@codex-reviewer-andresmgsl, #200 review — reproduced with one file).
|
|
||||||
printf 'CEREMONY_FORGE_CLIENT=gh\n' >"$root/local-tool-cache.txt"
|
|
||||||
check "an UNTRACKED marker-bearing file is ignored" 0 "" clean_tree_passes
|
|
||||||
check "...and is still ignored once it carries a decision" 0 "" clean_tree_passes
|
|
||||||
track
|
|
||||||
check "...but the moment it is TRACKED the guard sees it" 1 "local-tool-cache.txt" \
|
|
||||||
clean_tree_passes
|
|
||||||
git -C "$root" rm -q --cached local-tool-cache.txt 2>/dev/null
|
|
||||||
rm -f "$root/local-tool-cache.txt"
|
|
||||||
check "...and removing it restores green" 0 "" clean_tree_passes
|
|
||||||
|
|
||||||
# Path matching, both boundaries.
|
|
||||||
check "a directory entry does not match a sibling with the same prefix" 1 "" \
|
|
||||||
in_inventory drills-old/0.4.1.md
|
|
||||||
check "a file entry does not match a longer path" 1 "" in_inventory lib/forge.sh.backup
|
|
||||||
check "...while the real ones still match" 0 "" in_inventory drills/0.4.1.md
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for .github/scripts/vendored-check.sh (issue #251) — the
|
|
||||||
# self-guard that makes docs/VENDORED.txt authoritative over ceremony's OWN
|
|
||||||
# tree. Driven against constructed fixture trees plus the real one; the CI
|
|
||||||
# step runs the same script against the real tree.
|
|
||||||
#
|
|
||||||
# The fixture doc set is deliberately NOT ceremony's real six: a guard that
|
|
||||||
# hardcodes the vendored list instead of reading the manifest fails these
|
|
||||||
# rows, which is the failure the whole issue is about.
|
|
||||||
#
|
|
||||||
# set -u, not -e: failing commands 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/vendored-check.sh"
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
# --- fixture builders --------------------------------------------------------
|
|
||||||
|
|
||||||
# tree <name> <manifest-entry...> — a fixture tree carrying only the manifest.
|
|
||||||
tree() {
|
|
||||||
local dir="$TMP/$1"
|
|
||||||
shift
|
|
||||||
rm -rf "$dir"
|
|
||||||
mkdir -p "$dir/docs"
|
|
||||||
printf '%s\n' "$@" >"$dir/docs/VENDORED.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
# doc <tree> <path> [content] — a regular file in a fixture tree.
|
|
||||||
doc() {
|
|
||||||
local path="$TMP/$1/$2"
|
|
||||||
mkdir -p "$(dirname "$path")"
|
|
||||||
printf '%s\n' "${3:-# a doc}" >"$path"
|
|
||||||
}
|
|
||||||
|
|
||||||
# real_copy <name> — the real tree reduced to what the guard reads: the
|
|
||||||
# manifest, every path the manifest names, and every root *.md. Cases mutate
|
|
||||||
# the copy, so the guard's verdict on ceremony's actual doc set is proven
|
|
||||||
# without touching the working tree.
|
|
||||||
real_copy() {
|
|
||||||
local dir="$TMP/$1" entry
|
|
||||||
rm -rf "$dir"
|
|
||||||
mkdir -p "$dir/docs"
|
|
||||||
cp "$ROOT/docs/VENDORED.txt" "$dir/docs/VENDORED.txt"
|
|
||||||
cp "$ROOT"/*.md "$dir/"
|
|
||||||
while IFS= read -r entry; do
|
|
||||||
[ -n "$entry" ] || continue
|
|
||||||
mkdir -p "$dir/$(dirname "$entry")"
|
|
||||||
cp "$ROOT/$entry" "$dir/$entry"
|
|
||||||
done <"$ROOT/docs/VENDORED.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_check() {
|
|
||||||
bash "$CHECK" "$TMP/$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the happy tree ----------------------------------------------------------
|
|
||||||
|
|
||||||
# One manifest entry lives in a subdirectory: the manifest is PATHS, not
|
|
||||||
# filenames (docs-sync's fixtures prove the same), and the closed-world rule
|
|
||||||
# over the root must not regress that to root-only.
|
|
||||||
tree ok AGENTS.md RULES.md guide/DEEP.md
|
|
||||||
doc ok AGENTS.md
|
|
||||||
doc ok RULES.md
|
|
||||||
doc ok guide/DEEP.md
|
|
||||||
check "a tree whose root docs are all declared passes" 0 "3 manifest entries resolve" \
|
|
||||||
run_check ok
|
|
||||||
|
|
||||||
tree blanks AGENTS.md '' RULES.md
|
|
||||||
doc blanks AGENTS.md
|
|
||||||
doc blanks RULES.md
|
|
||||||
check "blank manifest lines are skipped, as docs-sync skips them" 0 "2 manifest entries" \
|
|
||||||
run_check blanks
|
|
||||||
|
|
||||||
# --- the closed world: a root doc in neither list ----------------------------
|
|
||||||
|
|
||||||
# The #248 near-miss, replayed as a test: a new doctrine file lands at the
|
|
||||||
# root and nobody adds it to the manifest.
|
|
||||||
tree newdoc AGENTS.md
|
|
||||||
doc newdoc AGENTS.md
|
|
||||||
doc newdoc NEWDOC.md
|
|
||||||
check "a root doc in neither list reds" 1 "'NEWDOC.md' is a root doc in NEITHER list" \
|
|
||||||
run_check newdoc
|
|
||||||
check "...and the refusal names the manifest fix" 1 "add 'NEWDOC.md' to docs/VENDORED.txt" \
|
|
||||||
run_check newdoc
|
|
||||||
check "...and the refusal names the exemption fix" 1 "add 'NEWDOC.md' to the exemption list" \
|
|
||||||
run_check newdoc
|
|
||||||
|
|
||||||
# The decision the guard forces, taken each way: vendor it…
|
|
||||||
tree newdoc-vendored AGENTS.md NEWDOC.md
|
|
||||||
doc newdoc-vendored AGENTS.md
|
|
||||||
doc newdoc-vendored NEWDOC.md
|
|
||||||
check "a root doc added to the manifest passes" 0 "2 manifest entries" \
|
|
||||||
run_check newdoc-vendored
|
|
||||||
|
|
||||||
# …or exempt it. The exemption list is in the script and carries a reason;
|
|
||||||
# README.md is one of the four ceremony-only root docs it names.
|
|
||||||
tree exempted AGENTS.md
|
|
||||||
doc exempted AGENTS.md
|
|
||||||
doc exempted README.md
|
|
||||||
check "a root doc on the exemption list passes, with its reason" 0 "exempt: README.md" \
|
|
||||||
run_check exempted
|
|
||||||
|
|
||||||
# The exemption list is NEVER read from prose. CONTRIBUTING.md's vendored-set
|
|
||||||
# sentence is documentation; two declarations of the same set is the drift
|
|
||||||
# the manifest exists to prevent (#251 D2's second must-fail).
|
|
||||||
tree prose AGENTS.md
|
|
||||||
doc prose AGENTS.md
|
|
||||||
doc prose EXTRA.md
|
|
||||||
doc prose CONTRIBUTING.md "The vendored set is AGENTS.md and EXTRA.md."
|
|
||||||
check "a doc declared only in prose still reds" 1 "'EXTRA.md' is a root doc in NEITHER list" \
|
|
||||||
run_check prose
|
|
||||||
|
|
||||||
# --- the rule is ROOT-level only ---------------------------------------------
|
|
||||||
|
|
||||||
# A guard that walked the tree would need an exemption list long enough that
|
|
||||||
# nobody reads it — the exact failure the root-only rule is shaped against.
|
|
||||||
# So an undeclared *.md under docs/, actions/ or drills/ must stay GREEN.
|
|
||||||
tree subdirs AGENTS.md
|
|
||||||
doc subdirs AGENTS.md
|
|
||||||
doc subdirs docs/CONSUMERS.md
|
|
||||||
doc subdirs actions/thing/README.md
|
|
||||||
doc subdirs drills/2026-07-01.md
|
|
||||||
check "undeclared *.md below the root stays green (no recursion)" 0 "1 manifest entries" \
|
|
||||||
run_check subdirs
|
|
||||||
|
|
||||||
# --- manifest → tree: the scan -----------------------------------------------
|
|
||||||
|
|
||||||
tree missing AGENTS.md GONE.md
|
|
||||||
doc missing AGENTS.md
|
|
||||||
check "a manifest entry with no file reds, naming it" 1 "names 'GONE.md' but the tree has no such file" \
|
|
||||||
run_check missing
|
|
||||||
|
|
||||||
tree symlinked AGENTS.md LINK.md
|
|
||||||
doc symlinked AGENTS.md
|
|
||||||
ln -s AGENTS.md "$TMP/symlinked/LINK.md"
|
|
||||||
check "a manifest entry pointing at a symlink reds" 1 "names 'LINK.md', which is a SYMLINK" \
|
|
||||||
run_check symlinked
|
|
||||||
|
|
||||||
tree dir-entry AGENTS.md guide
|
|
||||||
doc dir-entry AGENTS.md
|
|
||||||
mkdir -p "$TMP/dir-entry/guide"
|
|
||||||
check "a manifest entry pointing at a directory reds" 1 "names 'guide', which is a DIRECTORY" \
|
|
||||||
run_check dir-entry
|
|
||||||
|
|
||||||
tree empty-entry AGENTS.md HOLLOW.md
|
|
||||||
doc empty-entry AGENTS.md
|
|
||||||
: >"$TMP/empty-entry/HOLLOW.md"
|
|
||||||
check "a manifest entry pointing at an empty file reds" 1 "names 'HOLLOW.md', which is EMPTY" \
|
|
||||||
run_check empty-entry
|
|
||||||
|
|
||||||
# The escape case exists as a docs-sync fixture; ceremony's own manifest must
|
|
||||||
# not be the one place it goes unchecked.
|
|
||||||
tree escape AGENTS.md ../outside.md
|
|
||||||
doc escape AGENTS.md
|
|
||||||
check "a manifest entry escaping with ../ reds" 1 "names '../outside.md'" \
|
|
||||||
run_check escape
|
|
||||||
|
|
||||||
tree absolute AGENTS.md /etc/hosts
|
|
||||||
doc absolute AGENTS.md
|
|
||||||
check "an absolute manifest entry reds" 1 "names '/etc/hosts'" \
|
|
||||||
run_check absolute
|
|
||||||
|
|
||||||
# --- the manifest itself -----------------------------------------------------
|
|
||||||
|
|
||||||
rm -rf "$TMP/no-manifest"
|
|
||||||
mkdir -p "$TMP/no-manifest"
|
|
||||||
check "a tree with no manifest reds" 1 "no docs/VENDORED.txt under" \
|
|
||||||
run_check no-manifest
|
|
||||||
|
|
||||||
rm -rf "$TMP/empty-manifest"
|
|
||||||
mkdir -p "$TMP/empty-manifest/docs"
|
|
||||||
: >"$TMP/empty-manifest/docs/VENDORED.txt"
|
|
||||||
check "an empty manifest reds" 1 "is empty" run_check empty-manifest
|
|
||||||
|
|
||||||
# --- tracked-ness ------------------------------------------------------------
|
|
||||||
|
|
||||||
# A file present on this machine but absent from the tag's tree cannot be
|
|
||||||
# fetched by a consumer syncing at that tag. The assertion binds only where
|
|
||||||
# it can: when the tree IS a git work tree root.
|
|
||||||
tree tracked AGENTS.md RULES.md
|
|
||||||
doc tracked AGENTS.md
|
|
||||||
doc tracked RULES.md
|
|
||||||
git init -q "$TMP/tracked"
|
|
||||||
git -C "$TMP/tracked" add docs/VENDORED.txt AGENTS.md RULES.md
|
|
||||||
check "a git tree whose manifest entries are all tracked passes" 0 "2 manifest entries" \
|
|
||||||
run_check tracked
|
|
||||||
|
|
||||||
tree untracked AGENTS.md RULES.md
|
|
||||||
doc untracked AGENTS.md
|
|
||||||
doc untracked RULES.md
|
|
||||||
git init -q "$TMP/untracked"
|
|
||||||
git -C "$TMP/untracked" add docs/VENDORED.txt AGENTS.md
|
|
||||||
check "a git tree with an untracked manifest entry reds" 1 "names 'RULES.md', which is not TRACKED" \
|
|
||||||
run_check untracked
|
|
||||||
|
|
||||||
# ...and where it cannot bind, the skip ANNOUNCES ITSELF rather than being
|
|
||||||
# inferred from the absence of a refusal (#251 round 1). A guard that quietly
|
|
||||||
# stops asserting one of its four properties is the silent miss this whole
|
|
||||||
# script argues against, so the degradation is visible on both output paths.
|
|
||||||
check "a non-git tree says tracked-ness was not asserted" 0 "tracked-ness NOT asserted" \
|
|
||||||
run_check ok
|
|
||||||
check "...and says it on the red path too, beside the refusals" 1 "tracked-ness NOT asserted" \
|
|
||||||
run_check newdoc
|
|
||||||
|
|
||||||
# The converse, so the note is not simply always printed: where the tree IS a
|
|
||||||
# git work tree root the assertion bound, and nothing is announced.
|
|
||||||
no_skip_note() { ! run_check tracked 2>&1 | grep -qF "tracked-ness NOT asserted"; }
|
|
||||||
check "a git work tree root announces no skip — the assertion bound" 0 "" \
|
|
||||||
no_skip_note
|
|
||||||
|
|
||||||
# --- the real tree -----------------------------------------------------------
|
|
||||||
|
|
||||||
check "this tree, unmodified, is green" 0 "manifest entries resolve" bash "$CHECK" "$ROOT"
|
|
||||||
|
|
||||||
# The #248 near-miss on the REAL doc set: a scratch root doc nobody declared.
|
|
||||||
real_copy scratch
|
|
||||||
doc scratch SCRATCHDOC.md
|
|
||||||
check "a scratch root doc on the real tree reds, naming it" 1 "'SCRATCHDOC.md' is a root doc in NEITHER list" \
|
|
||||||
run_check scratch
|
|
||||||
|
|
||||||
# RELEASES.md stays listed — the regression criterion #248's review round
|
|
||||||
# bought, now asserted BY THE GUARD rather than by a hardcoded `grep -Fx` row
|
|
||||||
# in test/docs-sync.test.sh (#251 D1, D4). The closed world holds in both
|
|
||||||
# directions: dropping it from the manifest alone reds…
|
|
||||||
real_copy releases-dropped
|
|
||||||
grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-dropped/docs/VENDORED.txt"
|
|
||||||
check "dropping RELEASES.md from the manifest alone reds" 1 "'RELEASES.md' is a root doc in NEITHER list" \
|
|
||||||
run_check releases-dropped
|
|
||||||
|
|
||||||
# …and it is green only when the file leaves the root in the same breath.
|
|
||||||
real_copy releases-gone
|
|
||||||
grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-gone/docs/VENDORED.txt"
|
|
||||||
rm -f "$TMP/releases-gone/RELEASES.md"
|
|
||||||
check "dropping RELEASES.md from the manifest AND the root is green" 0 "manifest entries resolve" \
|
|
||||||
run_check releases-gone
|
|
||||||
|
|
||||||
summary
|
|
||||||
Loading…
Reference in a new issue