Skip to content

feat(ci): automate the vendored toolchain upgrades, with changelogs - #332

Merged
thecodedrift merged 8 commits into
mainfrom
ci/vendor-upgrade-pipeline
Sep 15, 2026
Merged

thecodedrift merged 8 commits into
mainfrom
ci/vendor-upgrade-pipeline

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Two vendored toolchains, three stages, one shared lifecycle — and a changelog on every proposal.

What was missing

Stage Before After
Vale republish (manifest + digests) automated, no changelog vendor/vale/republish, changelog in the body
Vale upgrade (the 6 pins a user gets) manual, every time vendor/vale/upgrade, automated
ast-grep upgrade (the 8 pins) nothing — badge only vendor/ast-grep/upgrade, automated

sg-detect.cjs could answer "is upstream ahead?" and had nowhere to put the answer: its only consumer was the README badge, which renders a version and cannot render a changelog. ast-grep 0.45.3 duly sat unpinned with nothing reporting it.

The Vale gap is subtler. release-vale.yml publishes @taskless/vale-* at <valeVersion>-<stamp>, which changes nothing for a consumer — the CLI pins each package exactly. Its own header says a release reaches a user "only when someone reviews a bump to that pin," and git log shows somebody always did, by hand, three times. So this automates a step that was working. What it removes is the dependency on remembering.

The publish path is untouched

Checked rather than assumed, because npm trusted publishing binds to specific values:

  • release-vale.yml is modified, not renamed.
  • Its gate, prepare, and publish jobs are byte-identical to main. Every edit is in detect.
  • Filename, name:, environment: npm-autopublish, and the single id-token: write are unchanged.
  • Neither new workflow declares id-token, an environment, a token, or npm publish. They hold contents: write + pull-requests: write.

The detect branch name changed (vale/update-<version>vendor/vale/republish). That is not part of the binding: publish fires on a push to main filtered by path, so it triggers on the merge regardless.

Rolling branches, and the two guards that make them safe

One branch per stage, rebuilt from main and retitled as upstream moves, rather than a branch per version. Previously a second upstream release opened a second PR proposing a conflicting edit to the same lines.

Rebuilding a branch someone may be reviewing is the hazard, so vendor-pr.cjs force-pushes only when:

  1. the proposed paths actually changed — otherwise every scheduled run dismisses approvals and restarts CI on an identical proposal; and
  2. the branch tip was written by this workflow — a reviewer's fixup is the most reasonable thing available to them, and a force-push would delete it with nothing reporting the loss.

Both are mutation-tested. I broke each and confirmed a test fails.

Two bugs my own tests caught

Truncation was budgeted against the wrong length. The 40k limit measured the source, but quoting adds two characters per line — ast-grep's changelog is a long list of short lines, so a 40,000-character body rendered as 80,202 and would have failed the API call outright rather than truncating. Now measured against rendered output, cut on a line boundary.

The unchanged-guard compared whole trees. An unrelated commit on main made the trees differ, so an upstream that had not moved still force-pushed and rewrote the PR over somebody else's edit to a different file. Now scoped to the paths being proposed.

Notable decisions

Release notes travel as a file, never a step output. They are third-party Markdown, and a $GITHUB_OUTPUT line is delimited text that a body containing the delimiter can break out of. They reach gh via --body-file and never meet a shell or a ${{ }} expression. Every line is blockquoted so an unterminated upstream code fence cannot swallow the body around it.

Pins are rewritten in the source text, not via JSON round-trip. Re-serializing reformats a prettier-formatted file in CI, where no lint-staged runs to normalize it back, burying the versions in a whole-file diff. The rewrite returns its count and the caller fails on a mismatch: these platform packages are selected by optional dependency, so a straggler at the old version is a different binary on one platform than the others.

The Vale upgrade reads all six packages from npm, not one. The publish loop can leave the set split across two versions, and that is precisely the state exact pins cannot tolerate. A split set fails the run.

vendor/ast-grep/upgrade, not vendor/ast-grep. Git refs are paths: a branch at the shorter name is a file there and makes vendor/ast-grep/<anything> impossible to create ever after.

Size

~2300 lines across 14 files, past the ~1200 guideline. It splits into four independently-safe units and I offered to stack it; the call was one PR. The four commits map to those units and are the intended reading order:

  1. shared modules (release-notes, pin-bump, vendor-pr)
  2. Vale republish — changelog + rolling branch
  3. ast-grep upgrade
  4. Vale upgrade

Roughly half is tests (425 passing) and about a third is comments.

No changeset

CI-only; nothing consumer-visible ships. The generated PRs each carry their own.

Verification

  • 425 script tests, pnpm lint, pnpm typecheck green.
  • vendor-pr.cjs driven through its full lifecycle against a real git repo with a bare origin: first proposal → same version again → main moves → upstream moves → reviewer pushes. All five behave; the reviewer's commit survives.
  • Both detect scripts run against live upstreams (Vale 3.21.0, ast-grep 0.45.3).
  • --write trialled on the real packages/cli/package.json: 8 pins, 8 insertions / 8 deletions, prettier --check clean, lockfile resolved. Reverted — this PR does not contain the 0.45.3 bump; the workflow proposes it once merged, which doubles as the end-to-end proof.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review - make sure we don't break OIDC and check that we have cleaned up these workflows so vale/sg upgrades are similar and separated from our vale binary releases (which are important, but are a different problem)

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 4m 32s —— View job


Review mode: incremental — read 0 prior review thread(s) before reviewing.

.prior-review.json contains only the trigger comment itself — no prior reviewThreads, reviews, or top-level comments exist on this PR. This is the first review of PR #332; everything below is assessed fresh against the full diff (gh pr diff, 2685 lines) plus whole-file reads of the changed scripts and workflows.

Your two specific asks

OIDC is not broken. Verified directly against .github/workflows/release-vale.yml: the publish job is the only place id-token: write appears, it still targets environment: npm-autopublish, and the workflow's filename is unchanged (only detect was edited; gate/prepare/publish are untouched). .github/workflows/vale-upgrade.yml and .github/workflows/ast-grep-upgrade.yml declare only contents: write + pull-requests: write, no environment:, no id-token, and never call npm publish. The npm trusted-publisher binding (which keys off this file's name + environment) is intact.

The separation looks clean. release-vale.yml (republish/binary release — detect + gate + prepare + publish) is a distinct file from vale-upgrade.yml (pin bump) and ast-grep-upgrade.yml (pin bump), triggered on different schedules, writing different rolling branches (vendor/vale/republish vs vendor/vale/upgrade vs vendor/ast-grep/upgrade), and only sharing the credential-free pin-bump.cjs/release-notes.cjs/vendor-pr.cjs modules. Nothing in the two new upgrade workflows touches the publish credential.

[New] Confirmed bug: changeset filename uses the stamped version, not the base version

.github/workflows/vale-upgrade.yml:112:

changeset=".changeset/vale-${VALE_VERSION//./-}.md"

The adjacent comment claims "The filename is stable per version, so a rolling branch rewrites it rather than accumulating a file per release." But VALE_VERSION is steps.detect.outputs.vale_version, which vale-upgrade-detect.cjs sets to upstream — the stamped version (3.21.0-20260914012345), not BASE_VERSION (the plain 3.21.0 used everywhere else in this same step, e.g. the PR title and body). Since every republish of Vale mints a fresh timestamp even for the same base version (documented explicitly in this PR's own tests: "a newer stamp of the same Vale version is still an upgrade"), the changeset filename churns on every republish instead of staying stable — the opposite of what the comment says and what the rolling-branch design intends. ast-grep-upgrade.yml:132 does this correctly (SG_VERSION there has no stamp concept), which makes the Vale one look like a one-line slip rather than a deliberate choice. Fix: use ${BASE_VERSION//./-} on line 112.

[New] vendor-pr.cjs force-pushes with --force, not --force-with-lease, despite two purpose-built guards against exactly this

.github/scripts/vendor-pr.cjs:170-202. The script already does real work to avoid clobbering a reviewer's commit: it fetches the remote branch, checks the tip author (git log -1 --format=%an FETCH_HEAD), and refuses to push if it's not github-actions[bot]. But the check and the push are not atomic — git fetch/git log happens, then (after the diff/identical check) git push --force origin branch runs unconditionally. If a reviewer pushes a fixup in that window, it is silently destroyed with no error — the precise failure the two guards exist to prevent, just from a timing gap the guards don't close. Since the script already holds the fetched SHA (FETCH_HEAD), swapping to git push --force-with-lease=${branch}:$(git rev-parse FETCH_HEAD) origin branch closes this for near-zero cost and turns the race into a normal, reported push failure instead of a silent overwrite.

[New] readNotesOut and collectPins are copy-pasted across the three detect scripts instead of extended into the shared modules this PR already introduced

  • readNotesOut(argv) — parse --notes-out <path>, throw if missing/looks like a flag — is byte-for-byte identical in sg-detect.cjs:239-249, vale-detect.cjs:94-104, and vale-upgrade-detect.cjs:93-103. All three already require("./release-notes.cjs") for other notes-related helpers; that's the natural home to export it from once instead of three times.
  • collectPins (enumerate dependencies/devDependencies/optionalDependencies by name prefix into a Map, error on disagreement) is independently written in sg-detect.cjs:164-178 and vale-upgrade-detect.cjs:114-145, with the same shape and the same disagreement-error pattern, differing only in PIN_PATTERN.test(name) vs name.startsWith(PIN_PREFIX).

This PR's own pin-bump.cjs was extracted specifically because "the rewrite is the same operation on a different prefix... worth having exactly one of" — that reasoning applies equally to the read/enumerate side and the arg-parsing side, but only the write side was consolidated. Not a correctness bug today, but a fix to either duplicated function (e.g. supporting --notes-out=path, or adding a scanned field) now has to be applied 2–3 times, and nothing would catch a partial fix.

[New] Minor: no test exercises vale-detect.cjs's new "no release" throw

.github/scripts/vale-detect.cjs:137-141 added if (!release) throw new Error(...) for a repository with no published GitHub release. vale-detect.test.cjs has no case where latestRelease resolves to undefined/falsy, so a future refactor that broke this branch (e.g. an accidentally-truthy empty object) would ship silently.

[New] Minor: bumpPins's prefix match isn't boundary-aware, unlike the collectPins it's paired with

sg-detect.cjs collects pins with a boundary-aware pattern (/^@ast-grep\/cli(-|$)/), but calls bumpPins(source, { prefix: "@ast-grep/cli", ... }), whose regex (pin-bump.cjs:40-43) matches "@ast-grep/cli[^"]*" — i.e. any key merely starting with that string, with no -/end-of-string boundary. Today the count !== pins.size guard would catch a real straggler, but it's a latent inconsistency between the two functions that are supposed to agree on what counts as "a pin" — worth a boundary-aware prefix in bumpPins (or documenting why collectPins's stricter pattern is intentionally not shared) rather than relying on the count check to paper over the difference. Low severity; no realistic dependency name collides with this today.

Not raised as findings (verified, working as intended)

  • The 40k-character truncation budget in release-notes.cjs:105-148 is correctly measured after quoting (the bug described in the PR body as fixed by tests is in fact fixed).
  • vale-release.cjs's exports/ordering semantics match what vale-upgrade-detect.cjs assumes; no cross-file signature mismatch found.
  • update-badges.cjs's calls into sg-detect.cjs/vale-detect.cjs never combine --json with --write/--notes-out, so the new mutual-exclusion guards in those scripts don't affect it.
  • Release notes reach the PR body only via --body-file/quoted heredocs, never via ${{ }} interpolation or $GITHUB_OUTPUT — matches the PR's stated injection-safety design.

Nothing above blocks the core OIDC/publish-path safety you asked about — that part checks out. The changeset-filename bug and the force-push race are the two I'd fix before merge; the duplication and test-gap items are cleanup, not correctness risks.

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 4m 32s… ### [New] Confirmed bug: changeset filename uses the stamped version…"
#332 (comment)

Verified all five findings against the code rather than taking them as read. Two were real and are fixed in 92d5755:

Force-push race — fixed, and it was the important one. The guard read the tip author and the push happened afterwards, so it was a check with a gap after it. The push now leases against the exact commit the guards inspected (--force-with-lease=<branch>:<fetched sha>), turning a silent overwrite into a rejected push. The explicit SHA also matters here specifically: actions/checkout clones at depth 1, so there may be no remote-tracking ref to lease against, which is the stale info failure this repo has hit before. A branch that does not exist yet takes an ordinary push — nothing to lease, nothing to overwrite.

Changeset filename — fixed. Now ${BASE_VERSION}. Worth noting the accumulation the comment worried about could not actually happen, because vendor-pr.cjs rebuilds the branch from main every run, so only one changeset file ever exists on it. The real defect was narrower and still worth fixing: the name churned on every republish while the note inside said the same thing, and the comment asserting stability was false.

Missing no release test — added, and mutation-checked along with the lease: reverting each fix makes its test fail.

Two left open, deliberately:

readNotesOut / collectPins duplication — correct that only the write side was consolidated. readNotesOut is byte-identical three times and should move into release-notes.cjs. collectPins is the more interesting one: the two versions differ in their error text on purpose, because sg-detect.cjs documents why it avoids vale-release.cjs's parser — an ast-grep oddity surfacing as an error message about Vale. A shared version needs a label parameter to preserve that, which is a real design choice rather than a lift-and-shift.

bumpPins prefix not boundary-aware — accurate. collectPins uses /^@ast-grep\/cli(-|$)/ and bumpPins matches any key merely starting with the prefix. The count !== pins.size guard makes it unreachable today, and that is exactly the argument for fixing it rather than against: the guard is papering over a disagreement between two functions that are supposed to agree.

Both are cleanup with no correctness risk today, and I have left them for the repo owner to call rather than growing a PR already past the size guideline.

— AI Coding Agent

Three small modules, each doing one thing the two vendored toolchains
(Vale and ast-grep) both need, so the behaviour is written and tested
once rather than described twice in shell.

release-notes.cjs fetches a GitHub release and renders it as a quoted
Markdown section. The quoting is load-bearing: an upstream body with an
unterminated code fence would otherwise swallow whatever the workflow
appends after it. The truncation limit is measured against the RENDERED
output, not the source — every line gains two characters of blockquote
marker, and budgeting against the source let a 40000-character body
render as 80202, over the 65536 cap that fails the API call outright
rather than truncating.

pin-bump.cjs moves a family of exact pins in the package.json SOURCE
TEXT. Parsing and re-serializing would reformat a prettier-formatted
file in CI, where no lint-staged runs to normalise it back, burying the
version strings in a whole-file diff. It returns the count it rewrote so
a caller can refuse a half-applied bump: these platform packages are
selected by optional dependency, so a straggler left at the old version
is a different binary on one platform than on the others.

vendor-pr.cjs owns the branch and pull request lifecycle — one rolling
branch per engine, rebuilt from main and retitled as upstream moves.
Rebuilding a branch someone may be reviewing is the hazard, so it
force-pushes only when the proposed paths actually changed, and only
when the branch tip was written by this workflow. Both guards are
mutation-tested.
The detect phase proposed a version and six digests and left a reviewer
to go find out, by hand, what the release contained. The release notes
were already in the payload: vale-detect.cjs calls releases/latest for
tag_name, and that same response carries body. It was being discarded.
Now --notes-out writes it to a file the workflow appends to the PR body.

A file, not a step output: release notes are third-party Markdown, and a
$GITHUB_OUTPUT line is delimited text that a body containing the
delimiter can break out of. The file reaches gh through --body-file and
never meets a shell or a ${{ }} expression.

The branch moves from vale/update-<version> to a rolling
vendor/vale/republish, and the branch and PR lifecycle is now
vendor-pr.cjs. Previously a second upstream release opened a second
pull request proposing a conflicting edit to the same lines; now the
open one is retitled and rewritten. That also fixes a quieter bug: the
old code exited early when a PR was already open, leaving a body naming
a version it no longer proposed.

The publish path is untouched. The gate, prepare and publish jobs are
byte-identical, and the filename, workflow name, environment and
id-token grant — the values npm's trusted publisher binding depends on —
are unchanged. The branch name is not part of that binding: publish
fires on a push to main filtered by path, so it triggers on the merge
regardless of what the branch was called.
sg-detect.cjs could answer "is upstream ahead?" and had nowhere to put
the answer: its only consumer was the README badge, which renders a
version and cannot render a changelog. So an ast-grep release was
discoverable by noticing a number on a badge, and 0.45.3 duly sat
unpinned with nothing reporting it.

This reverses that script's documented refusal to write, and the header
now says so rather than quietly dropping it. The half that was right is
unchanged — nobody merges a dependency bump unread, and Validate runs on
the PR like any other. The half that was wrong is that refusing to write
does not cause anyone to review anything; it only meant nothing was ever
proposed. A reviewed pull request is more review than no pull request.

The write is mechanical and bounded: pin-bump.cjs replaces a version
string in pins the script can already enumerate and fails the run if the
count does not match, so it cannot add a dependency or reformat the
file. The workflow regenerates the lockfile with pnpm --lockfile-only,
which also fails if upstream published the CLI without one of its
platform siblings, and writes a patch changeset — patch because the
package is pre-1.0, even though this is added functionality reaching a
consumer.

The branch is vendor/ast-grep/upgrade rather than a bare
vendor/ast-grep. Git refs are paths, so a branch at the shorter name is
a file there and makes vendor/ast-grep/<anything> impossible to create
ever after.
Vendoring Vale has two halves and only one was automated. release-vale.yml
watches upstream and republishes the six @taskless/vale-* packages at
<valeVersion>-<stamp>; that changes nothing for a consumer, because the
CLI pins each package exactly. Moving those pins is what ships a release,
and its own header says so: "a new version reaches a user only when
someone reviews a bump to that pin."

Somebody always did. git log on the pins shows 3.18.0, 3.19.0 and 3.20.0
each moved in its own hand-written commit, and the pins are current as
this lands — so this automates a step that was working. What it removes
is the dependency on remembering, and what it adds is upstream's
changelog next to the diff. The failure it prevents is quiet: a publish
lands, nobody notices the pins are behind, and the release does not ship.

Upstream here is npm, not the manifest. The manifest records what we
intend to publish; npm records what was published, and between them sits
a job that can fail. All six packages are read rather than one as a
representative, because the publish loop can leave the set split across
two versions and that is the one state exact pins cannot tolerate — some
platforms resolvable, others not. A split set fails the run.

The changelog is upstream's, fetched by the base version inside the
stamp. Our stamp records when a package was built, which tells a
reviewer nothing about what changed.

Daily rather than weekly, unlike the republish detect: that one waits on
upstream, this one waits on our own publish job, and a weekly schedule
would leave a release packaged-but-unshipped for up to a week after we
built it.
…e version

Two findings from review, both real.

vendor-pr.cjs read the branch tip's author and then force-pushed, which
is a check with a gap after it: a reviewer pushing a fixup inside that
gap lost the commit silently — the exact outcome the ownership guard
exists to prevent, reached by timing rather than by logic. The push now
leases against the commit the guards inspected, so a branch that moved
underneath us rejects the push and fails the run loudly instead. The SHA
is explicit rather than implied by a remote-tracking ref, since
actions/checkout clones at depth 1 and there may be no tracking ref to
lease against. A branch that does not exist yet has nothing to lease and
takes an ordinary push.

vale-upgrade.yml named the changeset after the stamped version while its
own comment claimed the filename was stable per version. A republish
mints a fresh timestamp for the same upstream Vale, so the name churned
on every republish while the release note inside it said the same thing.
It now uses the base version, which is what the title, the body, and the
note itself already said.

Also adds the missing test for vale-detect.cjs's "upstream has no
published releases" guard. Both new tests are mutation-checked: reverting
each fix makes them fail.
…ller

Two cleanup findings from review.

readNotesOut was byte-identical in all three detect scripts. It now lives
in release-notes.cjs beside the other notes helpers. All three parse the
same flag for the same reason, and a partial change — one script taught
a new form, two not — would leave a workflow silently writing no notes.

bumpPins took a bare string prefix and matched any key starting with it,
while the collectPins it is paired with used a boundary-aware pattern.
Two functions meant to agree on what counts as a pin disagreed, and only
the count check downstream kept that from mattering. Relying on a guard
to paper over a disagreement is not the same as not having one: the
guard turns it into a failed run, which beats a wrong bump and is worse
than the two agreeing. bumpPins now takes the pattern, so each caller
hands both functions the same constant and they cannot drift.

A /g pattern is refused rather than silently accepted. Its lastIndex
persists between .test() calls, so matching would alternate down the file
and skip every other pin — and the caller uses that same constant for its
own enumeration, where the bug would be just as quiet.

The bump tests move to pin-bump.test.cjs, beside the script they cover,
and gain the boundary and /g cases. Both fixes are mutation-checked:
reverting either makes its tests fail.

collectPins is deliberately NOT consolidated. Its two versions differ in
error text on purpose — sg-detect.cjs documents why it avoids
vale-release.cjs's parser, an ast-grep oddity surfacing as an error about
Vale — so sharing it means a label parameter to preserve that, which buys
less than it costs.
Reconciles with ecb1075, f3432ed and ea9cef8, which landed on main while
this branch was open. The two new workflows were written against the
pins that were current when the branch was cut, so after the rebase they
were the only two files left on actions/checkout v4, setup-node v4,
pnpm/action-setup v6 and a literal node-version: 24.

Nothing would have failed. That is the point: a stale pin does not go
red, it just quietly runs an older action, so this is exactly the kind of
drift a rebase carries in without reporting it.

Both jobs check out the repository, so they read the version from .nvmrc
like everything else. The one remaining literal is release-vale.yml's
publish step, which deliberately has no checkout for the file to be read
from and documents that in place.

Every workflow now resolves each action to a single SHA.
@thecodedrift
thecodedrift force-pushed the ci/vendor-upgrade-pipeline branch from fd43e93 to a0fbd38 Compare September 15, 2026 02:03
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

Second pass requested. Everything below landed after the first review, and the branch was rebased onto main, so every SHA is new.

Fixed from the first review:

  • vendor-pr.cjs force-push race — the tip-author guard and the push were not atomic. Now leases against the fetched SHA (--force-with-lease=<branch>:<sha>), explicit rather than via a tracking ref because actions/checkout clones at depth 1. New branches take an ordinary push.
  • vale-upgrade.yml changeset filename now uses BASE_VERSION.
  • Added the missing vale-detect.cjs "no published releases" test.

Cleanup, two of the three raised:

  • readNotesOut moved into release-notes.cjs; the three local copies are gone.
  • bumpPins now takes the caller's own pin pattern rather than a string prefix, so it and collectPins cannot disagree about what a pin is. A /g pattern is refused — its lastIndex would alternate down the file and skip every other pin. Bump tests moved to pin-bump.test.cjs.
  • collectPins deliberately not consolidated: the two versions differ in error text on purpose, since sg-detect.cjs documents avoiding vale-release.cjs's parser so an ast-grep oddity does not surface as an error about Vale. Reasoning is in the commit body.

Post-rebase reconcile:

  • The two new workflows were the only files left on the pre-node24 pins after ecb1075/f3432ed/ea9cef8 landed. Now on checkout v7.0.1, setup-node v7.0.0, pnpm/action-setup v6.1.0, reading node-version-file: .nvmrc.

Worth a second look specifically at the lease semantics in vendor-pr.cjs and at whether the pin reconcile missed anything, since a stale pin fails no check.

— AI Coding Agent

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 4m 23s —— View job


Review mode: incremental — read 0 prior review thread(s) before reviewing.

No reviewThreads exist on this PR (nothing to check isResolved against); all prior discussion is in top-level comments. Per the trigger comment's own change log and the author's verification reply, I re-checked each claim against the current code rather than taking it as read, and read every file in the diff (gh pr diff 332, plus whole-file reads of every changed script, test file, and workflow).

Treated as already addressed (not re-raised):

  • Force-push race in vendor-pr.cjs — now leases (--force-with-lease=<branch>:<fetched-sha>) against an explicit SHA rather than a tracking ref. Verified below.
  • Changeset filename in vale-upgrade.yml — now ${BASE_VERSION//./-} (line 115), not the stamped VALE_VERSION.
  • Missing "no published releases" test for vale-detect.cjs — present (vale-detect.test.cjs:258-267), and matches the new if (!release) throw at vale-detect.cjs:125-129.
  • readNotesOut duplication — consolidated into release-notes.cjs; confirmed only one definition exists (grep -rn "function readNotesOut" returns a single hit) and all three detect scripts import it.
  • bumpPins boundary-awareness — fixed by taking the caller's own pattern instead of a string prefix (pin-bump.cjs:54), with a /g pattern explicitly refused (pattern.global check) since a stateful lastIndex would skip every other pin. Both call sites (sg-detect.cjs:282, vale-upgrade-detect.cjs:235) now pass their own PIN_PATTERN, so collectPins and bumpPins cannot disagree about what counts as a pin.
  • collectPins duplication — deliberately left as two copies (different error text so an ast-grep oddity doesn't surface as a Vale error); this is a documented design decision, not something to re-flag.

Specifically requested: lease semantics in vendor-pr.cjs

Traced the full sequence (vendor-pr.cjs:154-231): git fetch origin branchfetchedTip = rev-parse FETCH_HEAD → tip-author guard reads FETCH_HEAD → scoped-path identical check also reads FETCH_HEAD → push uses --force-with-lease=${branch}:${fetchedTip}. FETCH_HEAD is never reassigned between the fetch and the push (no second fetch call in between), so the lease is asserted against the exact commit the guards actually inspected. A branch that doesn't exist yet (remoteExists === false) skips the lease and takes a plain push, which is correct — nothing to lease against. The explicit SHA (rather than a remote-tracking ref) sidesteps the stale info failure a depth-1 actions/checkout clone would otherwise produce. This matches the new test vendor-pr.test.cjs:272-293 ("the push leases against the exact commit the guards inspected"), and I don't see a gap: the only window between "read tip" and "push" is local computation (diff/identical check), nothing that could re-fetch or invalidate fetchedTip.

Specifically requested: node24 pin reconcile

Grepped every workflow for actions/checkout@, actions/setup-node@, and pnpm/action-setup@. ast-grep-upgrade.yml and vale-upgrade.yml now match every other workflow in the repo exactly: checkout 3d3c42e... (v7.0.1), setup-node 820762... (v7.0.0) with node-version-file: .nvmrc, pnpm/action-setup ea17c68... (v6.1.0). release-vale.yml's untouched publish job still uses a literal node-version: 24 (no checkout in that job, by design — documented in its own comment), which is pre-existing and outside this PR's scope. No stale pin found in the two new workflows.

New since last review

Nothing new. I read pin-bump.cjs/.test.cjs, release-notes.cjs, sg-detect.cjs/.test.cjs, vale-detect.cjs/.test.cjs, vale-upgrade-detect.cjs/.test.cjs, vendor-pr.cjs/.test.cjs, and all three workflow files in full, not just the hunks, and didn't find a correctness bug the previous pass missed. Two purely cosmetic observations, neither worth blocking on:

  • ast-grep-upgrade.yml:130-131's comment ("The filename is stable per version, so a rolling branch rewrites it rather than accumulating one file per release") is accurate for ast-grep today (its version has no timestamp component, unlike Vale's), but for the same reason established in the first review, accumulation couldn't happen anyway since vendor-pr.cjs rebuilds the branch from main every run — the comment's stated justification is slightly off even though its conclusion (stable filename) is correct and intentional.
  • The PR description's "driven through its full lifecycle against a real git repo with a bare origin" verification isn't present as a committed test file (vendor-pr.test.cjs uses a mocked git/gh harness, not a real repo) — read as manual pre-merge verification rather than an automated test claim, so not a gap in the suite itself.

Nothing here blocks merge. The two things this pass was specifically asked to re-check — lease semantics and the pin reconcile — both hold up.

The conclusion was right and the reason was not. A version-carrying
filename is there so two different bumps never share a release note;
accumulation is prevented by vendor-pr.cjs rebuilding the branch from
main every run, which leaves one changeset file on it whatever the name
is. The Vale side had the same false justification and was corrected
when its filename was fixed; this makes the two agree.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 4m 23s… Nothing here blocks merge. The two things this pass was specifically asked to re-check…"
#332 (comment)

Thanks — both re-checks confirmed, and the lease trace matches what I intended: fetchedTip is never reassigned between the fetch and the push, so the lease asserts exactly what the guards read.

Cosmetic point 1 — fixed in 9fce3b0. You are right that the comment's justification was off, and it is the same falsehood I corrected on the Vale side without carrying the fix across. The conclusion stands (a version-carrying filename keeps two different bumps from sharing a release note); the stated reason did not, since vendor-pr.cjs rebuilds the branch from main every run and leaves one changeset file on it whatever the name is. The two comments now agree.

Cosmetic point 2 — accurate, and deliberately worded that way. The real-git lifecycle run is manual pre-merge verification, listed under "Verification" in the PR body rather than claimed as suite coverage. vendor-pr.test.cjs is a mocked harness by design: it tests the order of operations, which is where the damage lives — pushing before checking who owns the branch, or opening a duplicate instead of updating the open PR, are both states every individual step reports as healthy.

That said, the manual run earned its keep: it is what caught the unchanged-check comparing whole trees, where an unrelated commit on main made a quiet upstream force-push the branch anyway. That is a genuine gap — the mocked harness cannot see it, because it is about real tree contents rather than call order. Committing it as an integration test (real repo, bare origin, gh stubbed) is worth doing, and I have raised it with the repo owner as a follow-up rather than growing a PR already past the size guideline.

— AI Coding Agent

@thecodedrift
thecodedrift merged commit ec6a2dd into main Sep 15, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the ci/vendor-upgrade-pipeline branch September 15, 2026 02:23
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.

1 participant