Stop charging every repository for an ordinary upgrade (#749) - #756
Conversation
After an upgrade, a repository wired before it refused commits under the PATH git actually gives a hook -- a GUI client, an IDE, a launcher -- until somebody re-ran `hooks install` there. #746 made that refusal say what it was; this stops it happening. I had this backwards and said so in #749: that relaxing the exit code was a fail-closed policy change I should not make alone. The exit code was never what held the property. The `exec` lives in the matching arm only, so a path the containment check refuses is already not executed by the time anything decides what to print -- `test/hooks.test.ts` asserts that directly with a witness file that must not appear, and it only sees `exit 1` at all because it passes a PATH with no `commitlore` on it. The refusal at the end is the ending for "no CLI could be resolved anywhere". Reusing it for "`current` moved and `root` did not" was a tax, not a fence. An upgrade is distinguishable by shape, and #71 is the opposite shape. `hooks install` writes `bin` as the literal `<data-root>/current/dist/commitlore.mjs` and `root` as the physical `v<x>` it resolved to. An upgrade moves the installer-owned symlink to a sibling: the recorded string does not change and the new target sits beside the recorded root. #71 replaces the string itself, and a `.git/config` editor can write neither the installer's symlink nor a directory beside its versioned trees. So that arm rebinds the trust root to what `current` resolves to now and runs the same containment check again. Two weaker rules are recorded in the comment as rejected rather than left to be rediscovered: "share a common ancestor" admits `/` and therefore everything, and "follow `current` wherever bin points" is satisfied by a planted `/tmp/current/dist/commitlore.mjs`. The second has a test, so reintroducing it fails rather than passes. Limit: this reaches a repository only when its stub already carries the arm, so one installed before this still needs `hooks install` once -- the same boundary #746 recorded, and the reason that command stays the named remedy Blast: system Undo: easy Certainty: firm Record-Id: r-upgraderebind Provenance: authored Verified: removing the rebind fails the upgrade case and leaves the other three passing, which is the shape a real guard has -- 88 tests across the five hook suites pass with it in place, including #71's witness assertion and the planted-layout case CommitLore-Version: 2.0.0
CommitLore — record lintTrailers: clean — 2 commits in Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR. |
Known residual, recorded rather than discovered laterOne arm this does not reach, and should not: deleting the old release directory after upgrading. That is the
Where the sibling check actually bearsWorth writing down, because only one axis of the rule is unforgeable: A Forging the whole data root — |
A blind refutation round on this branch broke three of the four claims I put to it, and verifying one of them found a defect it had not been looking for. `gh pr create` renders "GitHub closes #123 as merged" into the canonical pull request's body. GitHub binds a closing keyword to the number straight after it, and a pull request closed by keyword is recorded closed with `mergedAt` null -- the opposite of the sentence containing it, and the opposite of what T-1502 accepts. Measured on #752 six hours ago: an integration body said "GitHub closes #752, #755, #756 ... as merged", the keyword bound to #752 alone, and that one was recorded closed while the five with no keyword were recorded merged. There is no API to convert it afterwards. This workflow would have reproduced it on every run, and no test read the body. Two ticket statements were also wrong against the file. "The job never checks out or executes a pull request's head" was borrowed from the rule #723 fixed for `preserve`, which only reads a pull request; this one rebuilds it, and rebuilding somebody's change means running it. Unsatisfiable as written, so it would have been dropped rather than met -- what the job split actually holds is that the runner executing that code has no credential. And the negative control the ticket named, skipping `artifact:manifest`, cannot be performed from a pull request: the step is hard-coded in a workflow loaded from the default branch and the source-only filter refuses workflow edits. A negative control nobody can run is the defect it was written to prevent, so it is replaced with one that can be: edit `dist/` on the pushed canonical branch and watch `ci.yml` go red. Limit: the canonical pull request asks for a merge commit and cannot enforce one -- squash and rebase are both enabled and the button remembers the last method used, which is how #760 closed five of six as merged Blast: module Undo: easy Certainty: firm Record-Id: r-t1502body Provenance: authored Verified: restored the keyword and watched the new test fail naming `closes #123`, then restored the fix and saw 21 tests pass across both workflow test files CommitLore-Version: 2.0.0
Relates to #749 — it does not close it. See the last section.
After an upgrade, a repository wired before it refused commits under the PATH git actually gives a hook — a GUI client, an IDE, a launcher — until somebody re-ran
hooks installthere. #746 made that refusal say what it was; this stops it happening.I had this backwards, and said so on the issue
I wrote that relaxing the exit code was a fail-closed policy change I should not make alone. The exit code was never what held the property.
A path the containment check refuses is already not executed by the time anything decides what to print.
test/hooks.test.tsasserts that directly, with a witness file that must not appear — and it only seesexit 1at all because it passes a PATH with nocommitloreon it.So the refusal at the end is the ending for "no CLI could be resolved anywhere". Reusing it for "
currentmoved androotdid not" was a tax, not a fence.An upgrade is distinguishable by shape — and #71 is the opposite shape
A
.git/configeditor can write neither the installer's symlink nor a directory beside its versioned trees. So the arm rebinds the trust root to whatcurrentresolves to now and runs the same containment check again.Two weaker rules are recorded as rejected, in the comment and in a test:
/is a common ancestor, so it admits everythingcurrentwhereverbinpoints/tmp/current/dist/commitlore.mjsThe second has its own case, so reintroducing it fails rather than passes.
Verification
test/hook-upgrade-rebind.test.tsbuilds the<data-root>layout rather than describing it, because the property is in shell text only git runs.currentandrootare the same treecurrentmoved to a sibling,rootleft behind.jsoutside the installNegative control: removing the rebind fails the upgrade case and leaves the other three passing. 88 tests across the five hook suites pass with it in place.
Limit
This reaches a repository only when its stub already carries the arm, so one installed before this still needs
hooks installonce — the same boundary #746 recorded, and the reason that command stays the named remedy.Why this does not close #749
#749 asked two things. This answers the second — the refusal was a tax rather than a fence, and the tax is gone.
The first is still true after this ships. The rebind lives in the stub, and the stub is written into
.git/hooksat install time. A repository wired before this release keeps the old text, so the upgrade that delivers this fix is not fixed by it. Every affected repository still needscommitlore hooks installrun in it once.That is question 1 — "the repair cannot reach them" — and closing this issue would hide it behind a fix that does not apply to the machines that have the problem today.
Two corrections to what this PR claimed
"Does not widen the attack surface" was imprecise, and the precise version is more useful.
The second was checked on
origin/main, without the rebind: overwritingdist/commitlore.mjsinside the recorded root executes the planted code, because that root is exactly what the check trusts. So the rebind does not hand data-root write a capability it lacked — it extends an existing trust to a diverged state.A TOCTOU window exists, and it is not this change's.
Both
execsites have it —:260, the pre-existing matching arm, and:299, the rebind. So it is duplicated rather than introduced, and it is recorded here because it now appears twice and would otherwise be found later as a regression of this PR.