actions/docs-sync — the doctrine mirror is fetched from github.com, so a consumer on this forge is verified against a different tree with the same version number #201

Closed
opened 2026-08-05 11:06:39 +00:00 by claude-bot-andresmgsl · 2 comments

Context

Filed at @andres's direction (#198, #5369). Found while bounding the
hard-coded-host class @codex-reviewer-andresmgsl raised on #198 (#5341): that
issue's https://github.com/$REPO/issues/$n is introduced by the 0.6.0 merge
and is #198's to fix. This one is not — it is on main today at dad99dd,
predates the merge, and is unowned.

actions/docs-sync/docs-sync.sh:152 builds the fetch URL with the host written
in:

url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz"
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
  "docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) — does the pinned ref exist?"

docs-sync is the action that proves a consumer's vendored .ceremony/ mirror
is byte-identical to ceremony at the consumer's pinned ref (#19). On this forge
it fetches that reference tree from GitHub.

The two failure modes, measured

Both driven against the real hosts, 2026-08-05:

pinned ref github.com/.../archive/<ref>.tar.gz outcome
0.4.1 (a tag both forges carry) HTTP 200 wrong tree, silently
dad99dd… (a forge-only SHA) HTTP 404 hard fail, misleading message

The first is the dangerous one. heavy-duty/ceremony@0.4.1 exists on both
forges and they are not the same tree:

github  0.4.1 tarball -> VERSION=0.4.1, lib/forge.sh ABSENT
forge   0.4.1 tag     -> VERSION=0.4.1, lib/forge.sh, lib/forge-github.sh, lib/forge-forgejo.sh ALL PRESENT

So a consumer on this forge pinned to 0.4.1 has its mirror verified against a
tree missing the entire 1,133-line forge delta. --check either reports drift
the consumer cannot fix, or — worse — --fix rewrites a correct mirror into
the wrong one.

This is #197 decision 2's accepted hazard ("two trees answer to 0.6.0") with
a machine already acting on the wrong answer. The decision's stated mitigation
is provenance in prose; that mitigation does not reach a hard-coded fetch, and
after #198 lands the two trees diverge by more, not less.

The second mode is only cosmetically better: a consumer pinned to a
forge-only SHA gets does the pinned ref exist? — and it does exist, on the
forge it was pinned from.

Why nothing catches it

ci.yml's docs-sync-exercise passes source: ceremony-src on both
invocations (.github/workflows/ci.yml:173,178), so it takes the --source
override branch at :145-147 and never executes the tarball fetch. The
step's own comment says so: "The pin ref is scratch — --source overrides the
fetch"
.

That matters beyond this issue: the live-runner acceptance criterion being
added to #198 gates on those five ci.yml jobs, and docs-sync-exercise is
one of them. It would go green with this defect in place. The fetch path has no
coverage on either forge.

Spec

1. The host comes from the environment, not the source. Forgejo Actions
injects GITHUB_SERVER_URL, and lib/forge.sh:96 already switches on it to
detect the forge at all — so it cannot be unset on any path that reached this
code. Measured, the Forgejo archive URL is the same shape and serves both ref
kinds:

forgejo /heavy-duty/ceremony/archive/0.4.1.tar.gz      -> 200
forgejo /heavy-duty/ceremony/archive/dad99dd….tar.gz   -> 200   (a SHA GitHub 404s)

2. Outside Actions, it must refuse rather than guess. docs-sync is also
run by hand and by tests. With no GITHUB_SERVER_URL and no --source, it
must die naming the missing variable — not fall back to github.com. That is
lib/forge.sh's own rule, "Never 'probably github'", applied to a fetch
instead of a client.

3. The die message states the host it tried. It already interpolates
$url; keeping that is the whole fix to the second failure mode.

4. Out of scope: the prose links in docs-sync.sh:212,230 and
lib/ruling.sh (×4). They are documentation pointers in generated text, not
fetches, and they are the same on both sides of the 0.6.0 merge. Worth a
separate pass; conflating them with a functional fetch would make this issue
unreviewable.

Tasks

  • Build the fetch URL from GITHUB_SERVER_URL.
  • Refuse with a named reason when it is unset and --source was not given.
  • Keep the attempted URL in the failure message.
  • Cover the fetch path in test/docs-sync.test.sh — it has none today.
  • Add a changelog.d/ fragment.

Acceptance criteria

  • actions/docs-sync/docs-sync.sh contains no hard-coded github.com on
    any executable path.
  • With GITHUB_SERVER_URL set to this instance, the fetch resolves a
    forge-only SHA — the case that 404s today.
  • With GITHUB_SERVER_URL unset and no --source, the action exits
    non-zero naming the variable, and fetches nothing.
  • The failure message names the URL actually attempted.
  • test/run.sh ends failed 0; shellcheck- and actionlint-clean.

Test plan

  • Must fail before, pass after: stub the fetch, set GITHUB_SERVER_URL to
    this instance, and assert the requested URL's host is the forge. Today it is
    github.com regardless.
  • Must refuse: GITHUB_SERVER_URL unset, no --source — assert non-zero,
    assert the variable is named, and assert curl was never invoked.
  • Must not regress: the --source path is untouched, so
    docs-sync-exercise stays green exactly as it is.
  • The two live measurements above are re-runnable against both hosts and are
    the oracle for the tag-vs-SHA asymmetry.

Dependencies

None — independent of #198 and safe to land before or after it. Related:
#19 introduced the URL; #197 decision 2 is the version-collision hazard this
turns into a live defect; #198 fixes the other member of this class in
issueflow-reconcile.sh.


@andres — filed per your #5369. The evidence is two live fetches, not a
reading: same version number, different trees, HTTP 200 on the wrong one.
Labelled bug, scope:docs, ready — minted and normalized in one step as
the configured triage-actors identity (.github/labels.conf:3) and at your
instruction, rather than left needs-triage for myself to stamp later; flagging
that explicitly so the author-gate shortcut is visible rather than silent.

## Context Filed at @andres's direction (#198, #5369). Found while bounding the hard-coded-host class @codex-reviewer-andresmgsl raised on #198 (#5341): that issue's `https://github.com/$REPO/issues/$n` is introduced by the 0.6.0 merge and is #198's to fix. This one is **not** — it is on `main` today at `dad99dd`, predates the merge, and is unowned. `actions/docs-sync/docs-sync.sh:152` builds the fetch URL with the host written in: ```sh url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz" curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \ "docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) — does the pinned ref exist?" ``` `docs-sync` is the action that proves a consumer's vendored `.ceremony/` mirror is byte-identical to ceremony at the consumer's pinned ref (#19). On this forge it fetches that reference tree from **GitHub**. ## The two failure modes, measured Both driven against the real hosts, 2026-08-05: | pinned ref | `github.com/.../archive/<ref>.tar.gz` | outcome | |---|---|---| | `0.4.1` (a tag both forges carry) | **HTTP 200** | **wrong tree, silently** | | `dad99dd…` (a forge-only SHA) | **HTTP 404** | hard fail, misleading message | The first is the dangerous one. `heavy-duty/ceremony@0.4.1` exists on both forges and **they are not the same tree**: ``` github 0.4.1 tarball -> VERSION=0.4.1, lib/forge.sh ABSENT forge 0.4.1 tag -> VERSION=0.4.1, lib/forge.sh, lib/forge-github.sh, lib/forge-forgejo.sh ALL PRESENT ``` So a consumer on this forge pinned to `0.4.1` has its mirror verified against a tree missing the entire 1,133-line forge delta. `--check` either reports drift the consumer cannot fix, or — worse — `--fix` rewrites a correct mirror into the wrong one. This is #197 decision 2's accepted hazard ("two trees answer to `0.6.0`") with a machine already acting on the wrong answer. The decision's stated mitigation is provenance in prose; that mitigation does not reach a hard-coded fetch, and after #198 lands the two trees diverge by more, not less. The second mode is only cosmetically better: a consumer pinned to a forge-only SHA gets `does the pinned ref exist?` — and it does exist, on the forge it was pinned from. ## Why nothing catches it `ci.yml`'s `docs-sync-exercise` passes `source: ceremony-src` on **both** invocations (`.github/workflows/ci.yml:173,178`), so it takes the `--source` override branch at `:145-147` and **never executes the tarball fetch**. The step's own comment says so: *"The pin ref is scratch — `--source` overrides the fetch"*. That matters beyond this issue: the live-runner acceptance criterion being added to #198 gates on those five `ci.yml` jobs, and `docs-sync-exercise` is one of them. It would go green with this defect in place. The fetch path has no coverage on either forge. ## Spec **1. The host comes from the environment, not the source.** Forgejo Actions injects `GITHUB_SERVER_URL`, and `lib/forge.sh:96` already switches on it to detect the forge at all — so it cannot be unset on any path that reached this code. Measured, the Forgejo archive URL is the same shape and serves both ref kinds: ``` forgejo /heavy-duty/ceremony/archive/0.4.1.tar.gz -> 200 forgejo /heavy-duty/ceremony/archive/dad99dd….tar.gz -> 200 (a SHA GitHub 404s) ``` **2. Outside Actions, it must refuse rather than guess.** `docs-sync` is also run by hand and by tests. With no `GITHUB_SERVER_URL` and no `--source`, it must `die` naming the missing variable — not fall back to `github.com`. That is `lib/forge.sh`'s own rule, "Never 'probably github'", applied to a fetch instead of a client. **3. The `die` message states the host it tried.** It already interpolates `$url`; keeping that is the whole fix to the second failure mode. **4. Out of scope:** the prose links in `docs-sync.sh:212,230` and `lib/ruling.sh` (×4). They are documentation pointers in generated text, not fetches, and they are the same on both sides of the 0.6.0 merge. Worth a separate pass; conflating them with a functional fetch would make this issue unreviewable. ## Tasks - [ ] Build the fetch URL from `GITHUB_SERVER_URL`. - [ ] Refuse with a named reason when it is unset and `--source` was not given. - [ ] Keep the attempted URL in the failure message. - [ ] Cover the fetch path in `test/docs-sync.test.sh` — it has none today. - [ ] Add a `changelog.d/` fragment. ## Acceptance criteria - [ ] `actions/docs-sync/docs-sync.sh` contains no hard-coded `github.com` on any executable path. - [ ] With `GITHUB_SERVER_URL` set to this instance, the fetch resolves a **forge-only SHA** — the case that 404s today. - [ ] With `GITHUB_SERVER_URL` unset and no `--source`, the action exits non-zero naming the variable, and fetches nothing. - [ ] The failure message names the URL actually attempted. - [ ] `test/run.sh` ends `failed 0`; shellcheck- and actionlint-clean. ## Test plan - **Must fail before, pass after:** stub the fetch, set `GITHUB_SERVER_URL` to this instance, and assert the requested URL's host is the forge. Today it is `github.com` regardless. - **Must refuse:** `GITHUB_SERVER_URL` unset, no `--source` — assert non-zero, assert the variable is named, and assert `curl` was never invoked. - **Must not regress:** the `--source` path is untouched, so `docs-sync-exercise` stays green exactly as it is. - The two live measurements above are re-runnable against both hosts and are the oracle for the tag-vs-SHA asymmetry. ## Dependencies None — independent of #198 and safe to land before or after it. Related: #19 introduced the URL; #197 decision 2 is the version-collision hazard this turns into a live defect; #198 fixes the *other* member of this class in `issueflow-reconcile.sh`. --- @andres — filed per your #5369. The evidence is two live fetches, not a reading: same version number, different trees, HTTP 200 on the wrong one. Labelled `bug`, `scope:docs`, `ready` — minted and normalized in one step as the configured `triage-actors` identity (`.github/labels.conf:3`) and at your instruction, rather than left `needs-triage` for myself to stamp later; flagging that explicitly so the author-gate shortcut is visible rather than silent.
claude-bot-andresmgsl added the
bug
ready
scope:docs
labels 2026-08-05 11:06:39 +00:00
claude-bot-andresmgsl self-assigned this 2026-08-05 11:10:13 +00:00
claude-bot-andresmgsl added
claimed
and removed
ready
labels 2026-08-05 11:10:13 +00:00
Author
Member

Claimed and built — !203 is open

Claim transition done per BUILDER.md: self-assigned, readyclaimed. The
claim came with a PR rather than a promise of one, so the slot is short-lived.

Worth recording, since #192 is still open on exactly this: the label swap was
DELETE .../labels/107204 and POST .../labels200, under my PAT.
That is the control from #192's table reproducing exactly — removal works for a
PAT and fails only for the workflow token, which is why #192's fix is the
full-set PUT and not anything about this call.

What changed since the issue body: nothing in the diagnosis, one thing in
the evidence. ci.yml's docs-sync-exercise passes source: ceremony-src on
both invocations, so it takes the --source override and never runs the fetch.
The fetch path therefore had zero coverage before this PR — not thin
coverage, none — and the live-runner criterion being added to #198 would not
have caught this either, since docs-sync-exercise is one of the five jobs it
gates on.

Verification on the head, all local:

test/run.sh              22 test files, 0 failed
test/docs-sync.test.sh   68 passed, 0 failed   (59 before; +9 fetch-path rows)
shellcheck / actionlint  clean
must-fail-before proof   7 of the 9 new rows fail on main's script, 0 with the fix

The changelog-armed 300-character bound (#167) rejected my first fragment
draft at 424 characters. Guard works; entries split.

On closure: !203 says Refs #201, not Closes. The criteria are all
provable in CI, but what this actually fixes is a consumer's fetch, and
ceremony's own CI never exercises that path. The honest proof is a governed
consumer on this forge fetching from this forge — post-merge and triage-owned
per #151/#175. So this issue moves to post-merge on merge and stays open
until that is recorded, rather than closing with the PR.

@andres — review when you have a moment. Nothing merged, nothing closed.

## Claimed and built — !203 is open Claim transition done per BUILDER.md: self-assigned, `ready` → `claimed`. The claim came with a PR rather than a promise of one, so the slot is short-lived. Worth recording, since #192 is still open on exactly this: the label swap was `DELETE .../labels/107` → **204** and `POST .../labels` → **200**, under my PAT. That is the control from #192's table reproducing exactly — removal works for a PAT and fails only for the workflow token, which is why #192's fix is the full-set `PUT` and not anything about this call. **What changed since the issue body:** nothing in the diagnosis, one thing in the evidence. `ci.yml`'s `docs-sync-exercise` passes `source: ceremony-src` on both invocations, so it takes the `--source` override and never runs the fetch. The fetch path therefore had **zero** coverage before this PR — not thin coverage, none — and the live-runner criterion being added to #198 would not have caught this either, since `docs-sync-exercise` is one of the five jobs it gates on. Verification on the head, all local: ``` test/run.sh 22 test files, 0 failed test/docs-sync.test.sh 68 passed, 0 failed (59 before; +9 fetch-path rows) shellcheck / actionlint clean must-fail-before proof 7 of the 9 new rows fail on main's script, 0 with the fix ``` The `changelog-armed` 300-character bound (#167) rejected my first fragment draft at 424 characters. Guard works; entries split. **On closure:** !203 says `Refs #201`, not `Closes`. The criteria are all provable in CI, but what this actually fixes is a *consumer's* fetch, and ceremony's own CI never exercises that path. The honest proof is a governed consumer on this forge fetching from this forge — post-merge and triage-owned per #151/#175. So this issue moves to `post-merge` on merge and stays open until that is recorded, rather than closing with the PR. @andres — review when you have a moment. Nothing merged, nothing closed.

The Refs-linked PR merged with these acceptance criteria still unchecked:

  • Build the fetch URL from GITHUB_SERVER_URL.
  • Refuse with a named reason when it is unset and --source was not given.
  • Keep the attempted URL in the failure message.
  • Cover the fetch path in test/docs-sync.test.sh — it has none today.
  • Add a changelog.d/ fragment.
  • actions/docs-sync/docs-sync.sh contains no hard-coded github.com on
  • With GITHUB_SERVER_URL set to this instance, the fetch resolves a
  • With GITHUB_SERVER_URL unset and no --source, the action exits
  • The failure message names the URL actually attempted.
  • test/run.sh ends failed 0; shellcheck- and actionlint-clean.

The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition.

<!-- issueflow:post-merge-transition-pr-203 --> The Refs-linked PR merged with these acceptance criteria still unchecked: - [ ] Build the fetch URL from `GITHUB_SERVER_URL`. - [ ] Refuse with a named reason when it is unset and `--source` was not given. - [ ] Keep the attempted URL in the failure message. - [ ] Cover the fetch path in `test/docs-sync.test.sh` — it has none today. - [ ] Add a `changelog.d/` fragment. - [ ] `actions/docs-sync/docs-sync.sh` contains no hard-coded `github.com` on - [ ] With `GITHUB_SERVER_URL` set to this instance, the fetch resolves a - [ ] With `GITHUB_SERVER_URL` unset and no `--source`, the action exits - [ ] The failure message names the URL actually attempted. - [ ] `test/run.sh` ends `failed 0`; shellcheck- and actionlint-clean. The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition.
forgejo-actions added
post-merge
and removed
claimed
labels 2026-08-05 16:33:01 +00:00
claude-bot-andresmgsl was unassigned by forgejo-actions 2026-08-05 16:33:02 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#201
No description provided.