From 52e2b2def6562ba9594c3ff9d27d7f1426788536 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Sun, 9 Aug 2026 14:53:06 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20CLAUDE.md=20=E2=80=94=20PR=20prose?= =?UTF-8?q?=20closes=20issues=20too,=20via=20undocumented=20keyword=20infl?= =?UTF-8?q?ections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Closing note for #294" at the top of #311's body shut #294 one second after the merge. `closing` is not in GitHub's documented keyword list but behaves like one, and the grep the import-audit bullet prescribes does not match participles, so it would not have caught this. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c4c51c..6349164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h - There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it claims the same `ghcr.io//cryptify` name the old repo published, so nothing downstream has to repoint. `build-cryptify`, `scan-cryptify` and `finalize-cryptify` publish unconditionally as of 2026-08-07 (postguard#293): the `if: vars.PUBLISH_CRYPTIFY_IMAGE == 'true'` gate was removed once the existing GHCR package granted this repo Write, so this pipeline is now the **sole** publisher of `ghcr.io/encryption4all/cryptify`. The race is over: `encryption4all/cryptify`'s own build/push jobs were retired in cryptify#206 (merged 2026-08-07T14:00:28Z, the other half of #293), its release-plz jobs in cryptify#207 (merged 2026-08-09T12:24:06Z), and the repo is archived as of 2026-08-09 (#294) — its jobs are gone and an archived repo runs none, so nothing else can push that name. Archiving the *source* repo of a GHCR package does not by itself revoke the Write grant this repo holds on it: `Delivery` run 31313300399 (created 2026-08-09T12:26:46Z, on this PR's `1e5183fe`, with `encryption4all/cryptify` already archived) shows `Build cryptify (amd64)`/`(arm64)` passing and `Finalize cryptify manifest` pushing the **tag** `ghcr.io/encryption4all/cryptify:pr-311` at 12:28:13Z — the tag push, not just the by-digest push, is what proves tagging still works under an archived source repo. Both Dockerfiles pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. - `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, which runs on every PR as a **two-spec matrix** — `pg-pkg/api-description.yaml` (versioned `/v2` routes) and `cryptify/api-description.yaml` (unversioned) — so the check names carry the spec path. The matrix is not cosmetic: `pg-pkg/tests/api_gate.rs` and cryptify's in-crate `mod api_gate_tests` both grep this file for exactly **one** `fail-on:` and one `include-checks:` line, so two separate oasdiff steps would give two of each and fail both suites. Vary only the spec paths; moving `fail-on` into the matrix removes the literal both tests look for. `pg-pkg/tests/api_gate.rs` is the executable record of what the gate stops on the pg-pkg side: it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. - The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it. -- **Importing another repo's history here silently closes issues here. Two vectors fire it, each sufficient on its own, so rule out neither.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. **Vector 1, the squash body.** This repo's `squash_merge_commit_message` is `COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit), so squash-merging the import PR concatenates every imported commit message into the merge commit's body. `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed four seconds after it landed, with nothing warning, and stood four days before being found and reopened. Note what this means: a plain squash merge is enough by itself, *because* squashing concatenates the messages even as it throws the history away. **Vector 2, the history itself.** Here GitHub never reads the merge commit's message — it attributes the closes to the **imported commits**, once they become reachable from the default branch. This vector went untested in this repo rather than disproven: `9887e1a` carries no keywords of its own, and by the time it landed thirteen minutes later all 17 targets were already closed, and GitHub does not re-close a closed issue. It is measured in encryption4all/postguard-js#139, which is also the guard: import PR #137 merged with a real two-parent merge commit (`b1bb2ee`, a three-line message, no keywords, no squash body anywhere), and js#128/#129 closed six seconds later attributed to imported commits `a0ce27f`/`2acf42f` — single-parent, committed 2026-06-04 — then reopened fifteen minutes later. **So the audit is the only step that covers both.** Before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Editing the squash body in the merge dialog defuses vector 1 and only vector 1; on vector 2 there is no body to edit, and rewriting the keywords out of the imported commits costs every imported SHA (`git filter-repo`). Do **not** reach for `commit_message` on `PUT /pulls/N/merge` as the scripted version of that: the REST reference words it "Extra detail to append to automatic commit message" and documents nothing about `merge_method=squash`, so whether it replaces the concatenated body or appends to it is unverified — and if it appends, the defusal silently no-ops and every keyword still fires. Confirm it on a throwaway repo and record the answer here before relying on it. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272). +- **Importing another repo's history here silently closes issues here. Two vectors fire it, each sufficient on its own, so rule out neither.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. **Vector 1, the squash body.** This repo's `squash_merge_commit_message` is `COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit), so squash-merging the import PR concatenates every imported commit message into the merge commit's body. `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed four seconds after it landed, with nothing warning, and stood four days before being found and reopened. Note what this means: a plain squash merge is enough by itself, *because* squashing concatenates the messages even as it throws the history away. **Vector 2, the history itself.** Here GitHub never reads the merge commit's message — it attributes the closes to the **imported commits**, once they become reachable from the default branch. This vector went untested in this repo rather than disproven: `9887e1a` carries no keywords of its own, and by the time it landed thirteen minutes later all 17 targets were already closed, and GitHub does not re-close a closed issue. It is measured in encryption4all/postguard-js#139, which is also the guard: import PR #137 merged with a real two-parent merge commit (`b1bb2ee`, a three-line message, no keywords, no squash body anywhere), and js#128/#129 closed six seconds later attributed to imported commits `a0ce27f`/`2acf42f` — single-parent, committed 2026-06-04 — then reopened fifteen minutes later. **So the audit is the only step that covers both.** Before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Editing the squash body in the merge dialog defuses vector 1 and only vector 1; on vector 2 there is no body to edit, and rewriting the keywords out of the imported commits costs every imported SHA (`git filter-repo`). Do **not** reach for `commit_message` on `PUT /pulls/N/merge` as the scripted version of that: the REST reference words it "Extra detail to append to automatic commit message" and documents nothing about `merge_method=squash`, so whether it replaces the concatenated body or appends to it is unverified — and if it appends, the defusal silently no-ops and every keyword still fires. Confirm it on a throwaway repo and record the answer here before relying on it. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272). **A third vector needs no import at all: ordinary PR prose, because the keyword matcher accepts inflections the docs do not list.** PR #311's body opened "Closing note for #294" — meant as a pointer, not a directive — and merging it shut #294 one second later, `commit_id: null`, the PR-body attribution above. GitHub documents `close`/`closes`/`closed`, `fix`/`fixes`/`fixed`, `resolve`/`resolves`/`resolved`; `closing` is in none of those lists and behaved like one anyway, so treat the documented list as a floor rather than the set. In a PR body, keep **every** inflection of those three verbs away from a bare `#N` unless you mean it — write "context for #N", "part of #N", "follow-up to #N" — and grep the body before merging, with the participles included: `grep -oiE '(clos(e[sd]?|ing)|fix(e[sd]|ing)?|resolv(e[sd]?|ing)) +#[0-9]+'`. The narrower `(close[sd]?|fix(e[sd])?|resolve[sd]?)` pattern the import audit above uses does not catch this one. - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. - **Consolidating a repo into a monorepo means transfer its open issues first, then archive — not archive-then-orphan.** `postguard-website`, `postguard-outlook-addon`, `postguard-tb-addon` and `postguard-examples` were archived (read-only) after folding into `postguard-js`, and the "open issues transfer here" step was silently skipped on all four: 62 open issues sat stranded, unworkable (`HTTP 403: Repository was archived so is read-only` on label/assign/comment/close). GitHub will not transfer an issue out of an already-archived repo, so recovering from the skip costs an unarchive → transfer → re-archive round trip per repo instead of a single transfer before archiving (decided in postguard#282). Do the transfer as part of the same change that archives the repo, and leave a "development moved to ``, see ``" banner at the top of the archived repo's README before re-archiving, so an old link still finds the new home. **Open PRs strand exactly the same way, and release automation manufactures them**, which is what `cryptify` hit (#294): `release-plz-pr` re-opened the identical `chore: release v0.1.28` PR (cryptify#205) ~2 minutes after the README-banner commit (`6411843a`) was pushed to main, because that push re-ran the release-plz job living in cryptify's `ci.yml` before the old repo's release-plz jobs had been retired (#293 moved the Docker publish and deliberately left them) — not, as it first looked, the close of the superseded PR (cryptify#161): that close and #205's opening were 3m06s apart, and closing a PR fires no CI. So closing the open PRs is not enough — retire whatever re-creates them first, in the same commit that finalizes the README, or the merge of that very commit re-opens one. The banner is also the last chance to correct the README: cryptify's still said "this repository's CI is still the only publisher of `ghcr.io/encryption4all/cryptify` — do not remove its build/push steps yet", false since #293, and unfixable once archived. Order that works: retire release automation + finalize banner in one PR → close the open PRs → transfer the issues → archive. From 639c924fca30be78fed6340b3079a98d024d33ed Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Sun, 9 Aug 2026 15:22:22 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20CLAUDE.md=20=E2=80=94=20retract=20t?= =?UTF-8?q?he=20undocumented-keyword-inflection=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dobby's review on #313 showed PR #311's body carries a literal `Closes #294` trailer, which fully explains the close (commit_id: null matches the existing PR-body-attribution note) and which the existing narrow grep already matches — so the "third vector" evidence didn't hold up. Confirmed directly on a throwaway repo: a PR body of "Closing #N" (bare, adjacent, no other words) left the linked issue open and did not appear in closingIssuesReferences pre-merge. "closing" is not a closing keyword at all, in any form tested. Reverting to the pre-#313 bullet. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6349164..3c4c51c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the h - There are **two** Docker builds, `Dockerfile` (pg-pkg) and `cryptify/Dockerfile`, each with its own image name and independent version output in `delivery.yml`. cryptify's builds with the **repo root** as context (`file: cryptify/Dockerfile`), because the crate is a workspace member and needs the root manifest and lockfile; it claims the same `ghcr.io//cryptify` name the old repo published, so nothing downstream has to repoint. `build-cryptify`, `scan-cryptify` and `finalize-cryptify` publish unconditionally as of 2026-08-07 (postguard#293): the `if: vars.PUBLISH_CRYPTIFY_IMAGE == 'true'` gate was removed once the existing GHCR package granted this repo Write, so this pipeline is now the **sole** publisher of `ghcr.io/encryption4all/cryptify`. The race is over: `encryption4all/cryptify`'s own build/push jobs were retired in cryptify#206 (merged 2026-08-07T14:00:28Z, the other half of #293), its release-plz jobs in cryptify#207 (merged 2026-08-09T12:24:06Z), and the repo is archived as of 2026-08-09 (#294) — its jobs are gone and an archived repo runs none, so nothing else can push that name. Archiving the *source* repo of a GHCR package does not by itself revoke the Write grant this repo holds on it: `Delivery` run 31313300399 (created 2026-08-09T12:26:46Z, on this PR's `1e5183fe`, with `encryption4all/cryptify` already archived) shows `Build cryptify (amd64)`/`(arm64)` passing and `Finalize cryptify manifest` pushing the **tag** `ghcr.io/encryption4all/cryptify:pr-311` at 12:28:13Z — the tag push, not just the by-digest push, is what proves tagging still works under an archived source repo. Both Dockerfiles pin the same Rust today (`FROM rust:1.96.1-slim-trixie`), which is older or otherwise different from the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail a Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the current pins, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the image. - `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, which runs on every PR as a **two-spec matrix** — `pg-pkg/api-description.yaml` (versioned `/v2` routes) and `cryptify/api-description.yaml` (unversioned) — so the check names carry the spec path. The matrix is not cosmetic: `pg-pkg/tests/api_gate.rs` and cryptify's in-crate `mod api_gate_tests` both grep this file for exactly **one** `fail-on:` and one `include-checks:` line, so two separate oasdiff steps would give two of each and fail both suites. Vary only the spec paths; moving `fail-on` into the matrix removes the literal both tests look for. `pg-pkg/tests/api_gate.rs` is the executable record of what the gate stops on the pg-pkg side: it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`. - The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it. -- **Importing another repo's history here silently closes issues here. Two vectors fire it, each sufficient on its own, so rule out neither.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. **Vector 1, the squash body.** This repo's `squash_merge_commit_message` is `COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit), so squash-merging the import PR concatenates every imported commit message into the merge commit's body. `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed four seconds after it landed, with nothing warning, and stood four days before being found and reopened. Note what this means: a plain squash merge is enough by itself, *because* squashing concatenates the messages even as it throws the history away. **Vector 2, the history itself.** Here GitHub never reads the merge commit's message — it attributes the closes to the **imported commits**, once they become reachable from the default branch. This vector went untested in this repo rather than disproven: `9887e1a` carries no keywords of its own, and by the time it landed thirteen minutes later all 17 targets were already closed, and GitHub does not re-close a closed issue. It is measured in encryption4all/postguard-js#139, which is also the guard: import PR #137 merged with a real two-parent merge commit (`b1bb2ee`, a three-line message, no keywords, no squash body anywhere), and js#128/#129 closed six seconds later attributed to imported commits `a0ce27f`/`2acf42f` — single-parent, committed 2026-06-04 — then reopened fifteen minutes later. **So the audit is the only step that covers both.** Before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Editing the squash body in the merge dialog defuses vector 1 and only vector 1; on vector 2 there is no body to edit, and rewriting the keywords out of the imported commits costs every imported SHA (`git filter-repo`). Do **not** reach for `commit_message` on `PUT /pulls/N/merge` as the scripted version of that: the REST reference words it "Extra detail to append to automatic commit message" and documents nothing about `merge_method=squash`, so whether it replaces the concatenated body or appends to it is unverified — and if it appends, the defusal silently no-ops and every keyword still fires. Confirm it on a throwaway repo and record the answer here before relying on it. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272). **A third vector needs no import at all: ordinary PR prose, because the keyword matcher accepts inflections the docs do not list.** PR #311's body opened "Closing note for #294" — meant as a pointer, not a directive — and merging it shut #294 one second later, `commit_id: null`, the PR-body attribution above. GitHub documents `close`/`closes`/`closed`, `fix`/`fixes`/`fixed`, `resolve`/`resolves`/`resolved`; `closing` is in none of those lists and behaved like one anyway, so treat the documented list as a floor rather than the set. In a PR body, keep **every** inflection of those three verbs away from a bare `#N` unless you mean it — write "context for #N", "part of #N", "follow-up to #N" — and grep the body before merging, with the participles included: `grep -oiE '(clos(e[sd]?|ing)|fix(e[sd]|ing)?|resolv(e[sd]?|ing)) +#[0-9]+'`. The narrower `(close[sd]?|fix(e[sd])?|resolve[sd]?)` pattern the import audit above uses does not catch this one. +- **Importing another repo's history here silently closes issues here. Two vectors fire it, each sufficient on its own, so rule out neither.** Imported commits carry their original messages verbatim, closing keywords included, and GitHub resolves those against the **destination** repo's numbering. **Vector 1, the squash body.** This repo's `squash_merge_commit_message` is `COMMIT_MESSAGES` (the same setting the release-plz bullet above turns on its head — there it is why a `BREAKING CHANGE:` footer in a PR body never reaches the commit), so squash-merging the import PR concatenates every imported commit message into the merge commit's body. `ba380a1`'s body is 1678 lines and carries all 17 refs (`Closes #38 #45 #52 #54 #123 #125 #134 #142 #146 #153 #155 #157 #159 #167 #186 #191 #194`); 16 pointed at numbers already closed here, and one — #146, a live unimplemented feature request — was closed four seconds after it landed, with nothing warning, and stood four days before being found and reopened. Note what this means: a plain squash merge is enough by itself, *because* squashing concatenates the messages even as it throws the history away. **Vector 2, the history itself.** Here GitHub never reads the merge commit's message — it attributes the closes to the **imported commits**, once they become reachable from the default branch. This vector went untested in this repo rather than disproven: `9887e1a` carries no keywords of its own, and by the time it landed thirteen minutes later all 17 targets were already closed, and GitHub does not re-close a closed issue. It is measured in encryption4all/postguard-js#139, which is also the guard: import PR #137 merged with a real two-parent merge commit (`b1bb2ee`, a three-line message, no keywords, no squash body anywhere), and js#128/#129 closed six seconds later attributed to imported commits `a0ce27f`/`2acf42f` — single-parent, committed 2026-06-04 — then reopened fifteen minutes later. **So the audit is the only step that covers both.** Before merging *and* after, from the import branch: `git log origin/main..HEAD --pretty=%B | grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | sort -u`, then check each number here. Scope that range to the *imported* commits only — run it over a wider window and ordinary commits' keywords land in the count, which is how `#273`, closed legitimately by `a55c6a0` the day before, first got blamed on the merge. Editing the squash body in the merge dialog defuses vector 1 and only vector 1; on vector 2 there is no body to edit, and rewriting the keywords out of the imported commits costs every imported SHA (`git filter-repo`). Do **not** reach for `commit_message` on `PUT /pulls/N/merge` as the scripted version of that: the REST reference words it "Extra detail to append to automatic commit message" and documents nothing about `merge_method=squash`, so whether it replaces the concatenated body or appends to it is unverified — and if it appends, the defusal silently no-ops and every keyword still fires. Confirm it on a throwaway repo and record the answer here before relying on it. Reading close *state* cannot tell you which happened, so read the close **event** — and read it correctly, because the obvious rule is wrong. A keyword in a *commit message* is attributed to the commit and carries a `commit_id`; a keyword in the *PR body* is attributed to the PR and carries `commit_id: null`. #146 shows `ba380a1464…` because PR #277's body said only `Closes #255`, while #273 shows `null` because PR #274's body said `Closes #273`. So `null` means "not attributed to a commit", **not** "closed by hand": `gh api repos/OWNER/REPO/issues/N/timeline --paginate -q '.[] | select(.event=="closed") | .created_at+" "+(.commit_id//"-")'`. Same failure class as the unapplied-workflow half below (#272). - The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection. - **Consolidating a repo into a monorepo means transfer its open issues first, then archive — not archive-then-orphan.** `postguard-website`, `postguard-outlook-addon`, `postguard-tb-addon` and `postguard-examples` were archived (read-only) after folding into `postguard-js`, and the "open issues transfer here" step was silently skipped on all four: 62 open issues sat stranded, unworkable (`HTTP 403: Repository was archived so is read-only` on label/assign/comment/close). GitHub will not transfer an issue out of an already-archived repo, so recovering from the skip costs an unarchive → transfer → re-archive round trip per repo instead of a single transfer before archiving (decided in postguard#282). Do the transfer as part of the same change that archives the repo, and leave a "development moved to ``, see ``" banner at the top of the archived repo's README before re-archiving, so an old link still finds the new home. **Open PRs strand exactly the same way, and release automation manufactures them**, which is what `cryptify` hit (#294): `release-plz-pr` re-opened the identical `chore: release v0.1.28` PR (cryptify#205) ~2 minutes after the README-banner commit (`6411843a`) was pushed to main, because that push re-ran the release-plz job living in cryptify's `ci.yml` before the old repo's release-plz jobs had been retired (#293 moved the Docker publish and deliberately left them) — not, as it first looked, the close of the superseded PR (cryptify#161): that close and #205's opening were 3m06s apart, and closing a PR fires no CI. So closing the open PRs is not enough — retire whatever re-creates them first, in the same commit that finalizes the README, or the merge of that very commit re-opens one. The banner is also the last chance to correct the README: cryptify's still said "this repository's CI is still the only publisher of `ghcr.io/encryption4all/cryptify` — do not remove its build/push steps yet", false since #293, and unfixable once archived. Order that works: retire release automation + finalize banner in one PR → close the open PRs → transfer the issues → archive.