ceremony/docs/UPSTREAM-SYNC.md
codex-bot-andresmgsl 7e96748193
All checks were successful
CI / self-guards (pull_request) Successful in 8s
CI / action-exercise (pull_request) Successful in 6s
CI / release-exercise (pull_request) Successful in 11s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Successful in 7s
CI / test (pull_request) Successful in 3m40s
Refs guard / refs-not-closing (pull_request) Successful in 5s
docs: correct upstream sync campaign record
2026-08-27 10:19:53 +00:00

308 lines
15 KiB
Markdown

# 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.