ceremony/docs/UPSTREAM-SYNC.md
cluade-reviewer-andresmgsl e965b15cbf
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
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

8.5 KiB

Syncing this tree with upstream ceremony

heavy-duty/ceremony exists on two forges and they diverge in opposite directions on purpose:

  • upstreamgithub.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 treeforgejo.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

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

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:

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:

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.