Skip to content

Let a fork's records survive their own squash (#723) - #730

Merged
MongLong0214 merged 3 commits into
mainfrom
fix-723-fork-preserve
Aug 17, 2026
Merged

Let a fork's records survive their own squash (#723)#730
MongLong0214 merged 3 commits into
mainfrom
fix-723-fork-preserve

Conversation

@MongLong0214

Copy link
Copy Markdown
Owner

Closes #723.

preserve ran on pull_request, which hands a fork's workflow a read-only token. The job read the branch's commits, built the inherited note correctly, and could not publish it:

merge-type=squash  records=4  pushed=false
→ the record was attached on the runner and is about to be discarded with it

That is what happened to #720 — four records lost at the merge, recovered by hand afterwards, in the repository whose whole claim is that records survive a squash. contents: write was already declared; it cannot grant what the event does not carry.

Why pull_request_target is safe here

That event is better known for handing an attacker a write token, and the mechanism is specific: it checks out the base by default, and the hole opens when a workflow points the checkout at the fork's head and then runs it.

This job never has. It checks out base.ref — for an unrelated reason, because refs/pull/<n>/merge is gone once the PR closes — installs and builds from that tree, and brings the fork's commits in as refs/commitlore/pr-head, read for trailer blocks and nothing else. Data, never scripts.

The change is one line. What makes it safe was already true.

Being already true is not staying true

So the two rules are asserted rather than requested. test/preserve-workflow-safety.test.ts reads the workflow with comment lines stripped — a comment cannot satisfy it — and requires that the checkout names the base branch and that every command mentioning the fork ref is a git fetch.

mutation fails
revert the trigger to pull_request the trigger case
ref: github.event.pull_request.head.sha the base-branch case
git checkout refs/commitlore/pr-head -- . && npm ci three, incl. every mention is a fetch

What this does not do

It runs on close, so it publishes rather than gates: a merge that cannot carry its records still happens first, and the report is the only thing standing there. Making preserve a required check cannot fix that — the close event is after the merge by construction.

`preserve` ran on `pull_request`, which hands a fork's workflow a read-only token. So the job read the branch's commits, built the inherited note correctly, and could not publish it:

    merge-type=squash  records=4  pushed=false
    → the record was attached on the runner and is about to be discarded with it

That is what happened to #720. Four records were lost at the merge and recovered by hand afterwards, in the repository whose whole claim is that records survive a squash. `contents: write` was already declared; it cannot grant what the event does not carry.

`pull_request_target` carries it. That event is better known for handing an attacker a write token, and the mechanism is specific: it checks out the base by default, and the hole opens when a workflow points the checkout at the fork's head and then runs it.

This job never has. It checks out `base.ref` -- for an unrelated reason, because `refs/pull/<n>/merge` is gone once the pull request closes -- installs and builds from that tree, and brings the fork's commits in as `refs/commitlore/pr-head`, which is read for trailer blocks and nothing else. Data, never scripts. The change is one line; what makes it safe was already true.

Being already true is not the same as staying true, so the two rules are asserted rather than requested: the checkout must name the base branch, and every command mentioning the fork ref must be a `git fetch`. Reverting the event, pointing the checkout at the head, or running anything out of that ref each fail a named case.

Limit: this runs on close, so it publishes rather than gates -- a merge that could not carry its records still happens first, and the report is the only thing standing there
Limit: the fetched ref is trusted as data by every consumer downstream; a future step that treats a commit message as a command would reopen this by a different door
Ruled-out: make preserve a required check | it runs on the close event, so it cannot be a merge gate at all
Ruled-out: a maintainer runs squash-preserve after each fork merge | it is what I did for #720, and a manual step nobody is reminded of fails on the fifth occurrence rather than the first
Blast: system
Undo: easy
Certainty: firm
Record-Id: r-forkpreserve723
Provenance: authored
Verified: three mutations, each caught by the case whose name claims it -- reverting to `pull_request` fails the trigger case; pointing the checkout at `head.sha` fails the base-branch case; adding `git checkout refs/commitlore/pr-head -- .` before `npm ci` fails three, including the one asserting every mention of that ref is a fetch. Six cases pass on the file as it stands; the assertions read the workflow with comment lines stripped, so a comment cannot satisfy them.
CommitLore-Version: 2.0.0
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

CommitLore — record lint

Trailers: clean — 3 commits in origin/main..9d1d7b551e4791c835855d677fc7070b7a683948
Active constraints: not read — commitlore: git log --follow accepts exactly one pathspec, so renames are not followed for 3 paths; query one path at a time to follow its rename chain (3 changed paths)

Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR.

`action-preserve` asserted `on.pull_request.types` and #723 moved the workflow to `pull_request_target`, so the case died on `Cannot read properties of undefined` rather than on anything it was checking.

What it was checking is unchanged: the job fires on the closed event and nothing else. It now reads whichever key carries that. Which key it should be, and the two rules that keep the stronger event safe, are asserted in `test/preserve-workflow-safety.test.ts` -- a property belongs in one place, and that place is the file named after it.

Limit: this is the third guard this week whose subject moved out from under it; the pattern is a case pinned to a spelling rather than to the thing spelled
Blast: local
Undo: easy
Certainty: firm
Record-Id: r-trigkey723
Provenance: authored
Verified: 37 cases pass across action-preserve and preserve-workflow-safety together; before this the check jobs failed with `Cannot read properties of undefined (reading 'types')`, which named the spelling and not the property
CommitLore-Version: 2.0.0
@MongLong0214
MongLong0214 merged commit e5e1b09 into main Aug 17, 2026
12 checks passed
@MongLong0214
MongLong0214 deleted the fix-723-fork-preserve branch August 17, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A fork PR's records are discarded on squash: preserve cannot push notes, and it is not a required check

1 participant