docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
All checks were successful
CI / test (pull_request) Successful in 3m3s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s

The third child of #197, written immediately after performing the sync it
describes, while the findings are still first-hand.

docs/UPSTREAM-SYNC.md carries the procedure and the six standing resolutions,
each with the issue that decided it, so they are not re-argued every sync. The
parts that are not obvious from the outside, and that the 0.6.0 sync paid to
learn:

  * THE AUDIT STEP. `git merge` takes upstream's side wherever only upstream
    moved a region, so a function upstream ADDED to a file this tree owns
    arrives with no conflict and no question. Reviewing the hunks cannot find
    it — four reviewers read the same diff and each found a different subset.
    That was eight runtime `gh` call sites in three files and two file types.

  * THE SAME MECHANIC APPLIES TO STATE. A resolved region can remove a producer
    whose consumers auto-merged, and those consumers degrade to empty rather
    than erroring, so nothing goes red. Three such seams in one sync.

  * VERIFY WHERE IT RUNS. "Green locally" was wrong three times, for three
    different reasons: shellcheck-all lints TRACKED files so a new file's first
    lint is meaningless; CI pins shellcheck 0.10.0; and the runner's jq 1.6
    exits 0 where 1.7 exits 4 on `jq -e` with empty input — which was not a
    test problem but a guard accepting an unreadable read.

  * TEST THE MERGE RESULT. Forgejo tests heads, never what two branches produce
    together, and two green PRs did produce a red tree in this sync.

  * AFTER MERGING, CHECK THE SWEEP RECONCILED SOMETHING. The first post-merge
    run was green and had done nothing.

.upstream-ref records the carried commit in machine-readable form beside the
CHANGELOG's prose. test/upstream-delta.test.sh asserts every forge-DECIDING
file is named in the inventory — offline, comment-aware, and refusing rather
than skipping when the ref is missing. Shim CONSUMERS are allowed by name, so
a seventh consumer is silent and a seventh decider is not.

docs/CONSUMERS.md now states that two ceremonies answer to the same version
number and how a consumer says which one it pinned.

Must-fail, both from the issue's test plan: scattering a forge_detect branch
into an unlisted file reds the guard; blanking .upstream-ref reds it too.

test/run.sh 29 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0,
actionlint, self-ref, marker, vendored and changelog-armed all clean.

Refs #200
This commit is contained in:
cluade-reviewer-andresmgsl 2026-08-05 13:36:17 +00:00
parent 790c4d226f
commit e965b15cbf
5 changed files with 411 additions and 0 deletions

4
.upstream-ref Normal file
View file

@ -0,0 +1,4 @@
# The upstream commit this tree carries (docs/UPSTREAM-SYNC.md).
# Updated by each sync; read by test/upstream-delta.test.sh.
# github.com/heavy-duty/ceremony
8c3a4d1

31
changelog.d/200.md Normal file
View file

@ -0,0 +1,31 @@
### Added
- `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 to test the merge RESULT when other PRs are open: Forgejo tests
heads, so two green branches can produce a red tree (#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 forge branching into
a file the inventory does not name — offline, and refusing rather than
skipping when the recorded ref is missing (#200).
- `docs/CONSUMERS.md` states that two ceremonies answer to the same version
number, and how a consumer says which one it pinned (#200).

View file

@ -687,6 +687,26 @@ 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)).

192
docs/UPSTREAM-SYNC.md Normal file
View file

@ -0,0 +1,192 @@
# 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.
## 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
git merge-base main gh/main
```
**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 gh/main
```
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 SHA, machine-readable, checked by
`test/upstream-delta.test.sh`.
- 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.
**And check the merge result, not just the head.** Forgejo tests branch heads;
it never tests what two branches produce together. Two PRs can each be green
and their merge red — that happened in this sync, because a rule the sync
*introduces* (#262's terminal citation) was not met by a fragment written
against the base that lacks it. If other PRs are open, merge them together
locally and run the suite on the result.
### 8. After it merges
The sync issue uses `Refs`, not `Closes`, and stays open until a real sweep on
the merged `main` is linked to it. A green workflow run is not that evidence by
itself: verify the run actually **reconciled** something. In this sync the
first post-merge run was green and had done nothing at all, because upstream's
restructure moved reconcile behind a dispatch this forge cannot perform.
## 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.
| 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 |
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.

164
test/upstream-delta.test.sh Normal file
View file

@ -0,0 +1,164 @@
#!/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"
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
ref_looks_like_sha() { printf '%s' "$(recorded_ref)" | grep -qE '^[0-9a-f]{7,40}$'; }
check "...and looks like a commit SHA" 0 "" ref_looks_like_sha
# The ancestry half needs upstream's objects, which a CI clone of THIS repo
# does not have. It is therefore conditional — but it says so out loud rather
# than passing quietly, because "could not check" and "checked and fine" are
# the two states this repo exists to keep apart.
ancestry_state() {
if git -C "$ROOT" cat-file -e "$(recorded_ref)^{commit}" 2>/dev/null; then
git -C "$ROOT" merge-base --is-ancestor "$(recorded_ref)" HEAD 2>/dev/null \
&& echo ANCESTOR || echo NOT-ANCESTOR
else
echo UNVERIFIABLE-HERE
fi
}
state="$(ancestry_state)"
printf 'upstream-delta: recorded ref %s is %s\n' "$(recorded_ref)" "$state"
ancestry_acceptable() { [ "$state" = ANCESTOR ] || [ "$state" = UNVERIFIABLE-HERE ]; }
check "the recorded ref is an ancestor of HEAD, or is honestly reported as unverifiable" 0 "" \
ancestry_acceptable
# --- 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
in_inventory() { # $1 = repo-relative path
local entry
while IFS= read -r entry; do
[ -n "$entry" ] || continue
case "$1" in "$entry" | "$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 and the
# environment override that drives them. Comments are stripped — this tree
# documents the forge split at length, and a guard that read prose as evidence
# would flag every file that merely explains the design.
FORGE_MARKERS='forge_detect|forge_select|forge_preflight|forge_client|CEREMONY_FORGE\b|CEREMONY_FORGE_CLIENT'
forge_specific_files() {
local f rel
for f in "$ROOT"/lib/*.sh "$ROOT"/actions/*/*.sh "$ROOT"/bin/* \
"$ROOT"/.github/scripts/*.sh; do
[ -f "$f" ] || continue
rel="${f#"$ROOT"/}"
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$f" \
| grep -qE "$FORGE_MARKERS" && printf '%s\n' "$rel"
done
return 0
}
# A call site that merely USES the shim is not forge-specific — every
# reconciler calls forge_preflight and that is the point of the shim. What the
# inventory is about is where the forge is DECIDED or BRANCHED ON. So the scan
# reports files carrying the markers, and the assertion below allows the shim's
# own consumers explicitly, by name, so adding a seventh consumer is silent but
# adding a seventh DECIDER is not.
SHIM_CONSUMERS='actions/issueflow-reconcile/issueflow-reconcile.sh
actions/labels-reconcile/labels-reconcile.sh
actions/labels-scope/labels-scope.sh
actions/refs-not-closing/run.sh
lib/facts.sh
lib/ruling.sh
lib/attention.sh'
unlisted_deciders() {
local rel
while IFS= read -r rel; do
[ -n "$rel" ] || continue
grep -qxF "$rel" <<<"$SHIM_CONSUMERS" && continue
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 --------------------------------------------------------------------
# The sweep above is a property of the whole tree and cannot be made to fail
# without editing it, so the predicates are driven directly.
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' 'case "$(forge_detect)" in forgejo) : ;; esac' \
>"$TMP/scattered.sh"
# Byte-identical to the strip forge_specific_files uses: a helper that
# normalised differently would be testing a predicate the scan does not have.
marker_seen() {
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" | grep -qE "$FORGE_MARKERS"
}
check "a new file branching on forge_detect is seen" 0 "" marker_seen "$TMP/scattered.sh"
printf '%s\n' '#!/usr/bin/env bash' '# forge_detect used to live here' >"$TMP/prose.sh"
check "prose about forge_detect is not" 1 "" marker_seen "$TMP/prose.sh"
check "...and such a file would not be in the inventory" 1 "" in_inventory scattered.sh
summary