docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
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
2026-08-05 13:36:17 +00:00
|
|
|
# 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.
|
|
|
|
|
|
2026-08-05 13:42:21 +00:00
|
|
|
### Every branch that was open during the sync is now stale
|
docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
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
2026-08-05 13:36:17 +00:00
|
|
|
|
2026-08-05 13:42:21 +00:00
|
|
|
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
|
docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
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
2026-08-05 13:36:17 +00:00
|
|
|
|
|
|
|
|
The sync issue uses `Refs`, not `Closes`, and stays open until a real sweep on
|
2026-08-05 13:42:21 +00:00
|
|
|
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.
|
docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
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
2026-08-05 13:36:17 +00:00
|
|
|
|
|
|
|
|
## 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.
|