# 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.0` through `0.7.4` line remain deferred to the next sync campaign. If that campaign merges rather than ports, it is the campaign that advances `.upstream-ref`; another port leaves the ancestry baseline unchanged. ## 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.