From ad9796e76196636a9431d678a6520b9b182c4f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 20:56:50 -0300 Subject: [PATCH 1/9] fix(ci): install protoc for the release build, and allow republishing a tag `bun run build` regenerates the proto codec through protoc, which is not on the runner. ci.yml never caught it because it calls wasm-pack and build:ts directly and skips `gen`, so the release job was the first thing to run the package's real build. It failed on v0.6.1 at that step. The gate held: the tag and the GitHub release exist, npm is untouched. Recovering from that is the second half of this change. Only the run that creates a tag carries `release_created`, and re-running it replays the workflow file of that same commit, which is the broken one. A dispatch trigger takes an existing tag and puts it through verify and publish with the workflow as it stands. --- .github/workflows/release.yml | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e808a2..17dd330 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,17 @@ name: Release on: push: branches: [main] + # Recovery path. A release tag is created by the job below and only that run + # carries `release_created`, so a publish that fails for a reason outside the + # tag — a missing tool on the runner, a registry outage — cannot be retried by + # re-running it: the re-run replays the workflow file of that commit, which is + # the one that failed. Dispatch builds and publishes an existing tag with the + # workflow as it stands now. + workflow_dispatch: + inputs: + tag: + description: Existing release tag to build and publish + required: true permissions: contents: read @@ -26,6 +37,7 @@ env: jobs: release-please: name: Prepare release + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -77,7 +89,7 @@ jobs: verify: name: Verify the tag needs: release-please - if: needs.release-please.outputs.release_created == 'true' + if: always() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 45 permissions: @@ -85,7 +97,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: ${{ inputs.tag || needs.release-please.outputs.tag_name }} persist-credentials: false - name: Install Rust @@ -100,7 +112,9 @@ jobs: - name: Install wasm-pack uses: taiki-e/install-action@91ddec75689c4c78665b598d188dc821c5a43e5c # v2.85.9 with: - tool: wasm-pack + # protoc: `bun run build` regenerates the proto codec, which ci.yml + # never exercises because it calls wasm-pack and build:ts directly. + tool: wasm-pack,protoc - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -131,7 +145,7 @@ jobs: publish: name: Publish to npm needs: [release-please, verify] - if: needs.release-please.outputs.release_created == 'true' + if: always() && needs.verify.result == 'success' runs-on: ubuntu-latest timeout-minutes: 45 # Gate for the publish step: add required reviewers here to make releases @@ -145,7 +159,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: ${{ inputs.tag || needs.release-please.outputs.tag_name }} # Nothing here talks to the remote after checkout, and the steps below # run cargo, bun and third-party build scripts in a job holding OIDC # publish rights. Don't leave the token in .git/config for them. @@ -163,7 +177,9 @@ jobs: - name: Install wasm-pack uses: taiki-e/install-action@91ddec75689c4c78665b598d188dc821c5a43e5c # v2.85.9 with: - tool: wasm-pack + # protoc: `bun run build` regenerates the proto codec, which ci.yml + # never exercises because it calls wasm-pack and build:ts directly. + tool: wasm-pack,protoc # Pinned, unlike ci.yml: this bun builds the wasm artifact that ships to # npm, so a floating version would change the published bytes without a From 4cd4190435c714baf21db15b915078f86b77efa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 21:27:16 -0300 Subject: [PATCH 2/9] fix(ci): scope the recovery dispatch to a tag and pin what publish ships Taking the tag as a free-form input let a dispatch point the privileged publish job at any branch or commit, and a mutable ref could advance between the verify checkout and the publish one, shipping code that was never verified. npm's provenance compounded it: it records the event's ref and sha, not the workspace, so a tarball built from a tag while the event pointed at a branch would be attested to the wrong commit. The dispatch now carries no input. Select the tag as the ref instead: the picker only offers refs that exist, `ref_type` rejects anything that is not a tag, and the event then points at the same commit the provenance will name. Publish checks out the sha verify resolved rather than the ref again, so there is no window between the two. Publish also gated on `!cancelled()` rather than `always()`, which ran through cancellation as long as verify had already succeeded. --- .github/workflows/release.yml | 37 +++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17dd330..fed99f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,13 +15,17 @@ on: # carries `release_created`, so a publish that fails for a reason outside the # tag — a missing tool on the runner, a registry outage — cannot be retried by # re-running it: the re-run replays the workflow file of that commit, which is - # the one that failed. Dispatch builds and publishes an existing tag with the - # workflow as it stands now. + # the one that failed. + # + # Dispatch this **with the release tag selected as the ref**, not from a + # branch with the tag as a parameter. Taking it as a free-form input would let + # a dispatch publish any branch or commit through a job holding npm OIDC + # rights, and npm's provenance records the event's ref and sha rather than + # whatever the workspace was checked out to — so a tarball built from a tag + # while the event pointed at a branch would be attested to the wrong commit. + # The ref picker only offers refs that exist, and `ref_type` below rejects + # everything that is not a tag. workflow_dispatch: - inputs: - tag: - description: Existing release tag to build and publish - required: true permissions: contents: read @@ -89,17 +93,30 @@ jobs: verify: name: Verify the tag needs: release-please - if: always() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') + # `!cancelled()` rather than `always()`: the dispatch path needs this to run + # past a skipped release-please, but a cancelled run must not go on to + # publish. + if: >- + !cancelled() && + (needs.release-please.outputs.release_created == 'true' || + (github.event_name == 'workflow_dispatch' && github.ref_type == 'tag')) runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read + outputs: + # The exact commit this job verified. Publish checks that out instead of + # resolving the ref a second time, so nothing can move in between. + sha: ${{ steps.verified.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.tag || needs.release-please.outputs.tag_name }} + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || format('refs/tags/{0}', needs.release-please.outputs.tag_name) }} persist-credentials: false + - id: verified + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Install Rust uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: @@ -145,7 +162,7 @@ jobs: publish: name: Publish to npm needs: [release-please, verify] - if: always() && needs.verify.result == 'success' + if: ${{ !cancelled() && needs.verify.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 45 # Gate for the publish step: add required reviewers here to make releases @@ -159,7 +176,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.tag || needs.release-please.outputs.tag_name }} + ref: ${{ needs.verify.outputs.sha }} # Nothing here talks to the remote after checkout, and the steps below # run cargo, bun and third-party build scripts in a job holding OIDC # publish rights. Don't leave the token in .git/config for them. From 92a768698a1e0ea2b78163b3223e5d867b23a35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 21:35:25 -0300 Subject: [PATCH 3/9] fix(ci): check out the dispatch event's sha, not its ref Publish already consumed the sha verify resolved, but verify still resolved the ref itself. A tag force-updated between the event and that checkout would be built and shipped while npm provenance kept naming the commit the event recorded, so the attestation would describe code that never ran. The event sha is immutable, so use it on the dispatch path. The push path keeps the tag, where the event sha is the main commit rather than the release. --- .github/workflows/release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fed99f7..cb01d9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,7 +111,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || format('refs/tags/{0}', needs.release-please.outputs.tag_name) }} + # On dispatch, the sha the event recorded rather than the ref: a tag + # force-updated between the event and this checkout would otherwise be + # built here while provenance still names the original commit. On the + # push path the event sha is the main commit, so the tag is the ref. + ref: ${{ github.event_name == 'workflow_dispatch' && github.sha || format('refs/tags/{0}', needs.release-please.outputs.tag_name) }} persist-credentials: false - id: verified From e4f66c7baa7a813756633f2e7505fb4a1fac649e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 21:46:11 -0300 Subject: [PATCH 4/9] ci: check out the release commit on the push path too The dispatch path already used the event's sha; the push path still resolved the tag name, so it kept the window the dispatch fix closed and built a ref while npm attested the event's commit. Both paths now check out that sha, which drops the output threading the resolved commit from verify to publish: there is nothing left to thread when neither job resolves anything. release-please tags the commit it merged, and verify asserts that rather than assuming it. --- .github/workflows/release.yml | 37 +++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb01d9d..128b046 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,22 +104,34 @@ jobs: timeout-minutes: 45 permissions: contents: read - outputs: - # The exact commit this job verified. Publish checks that out instead of - # resolving the ref a second time, so nothing can move in between. - sha: ${{ steps.verified.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # On dispatch, the sha the event recorded rather than the ref: a tag - # force-updated between the event and this checkout would otherwise be - # built here while provenance still names the original commit. On the - # push path the event sha is the main commit, so the tag is the ref. - ref: ${{ github.event_name == 'workflow_dispatch' && github.sha || format('refs/tags/{0}', needs.release-please.outputs.tag_name) }} + # The commit the event recorded, never a ref. A ref can move between + # the event and either checkout, and npm records this sha as the + # package's source regardless of what the workspace holds — so + # resolving a tag here could publish one tree while attesting another. + ref: ${{ github.sha }} persist-credentials: false - - id: verified - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Guards the assumption on the push path: release-please tags the commit + # it just merged, so a tag pointing elsewhere means this run would verify + # and publish something the release does not name. + - name: Confirm the release tag points at this commit + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") + target=$(jq -r '.object.sha' <<<"$ref") + if [ "$(jq -r '.object.type' <<<"$ref")" = tag ]; then + target=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$target" --jq '.object.sha') + fi + if [ "$target" != "$GITHUB_SHA" ]; then + echo "::error::$TAG points at $target, not $GITHUB_SHA" + exit 1 + fi - name: Install Rust uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master @@ -180,7 +192,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.verify.outputs.sha }} + # Same immutable commit verify checked out and vouched for. + ref: ${{ github.sha }} # Nothing here talks to the remote after checkout, and the steps below # run cargo, bun and third-party build scripts in a job holding OIDC # publish rights. Don't leave the token in .git/config for them. From 6db11c97474b5596e66259b7d50f03c4f7853e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 21:48:00 -0300 Subject: [PATCH 5/9] docs(ci): record which tags the recovery dispatch can reach A dispatch runs the workflow file stored in the selected tag, so the recovery path only covers tags created from this commit onwards. That was answered in review and nowhere in the file, which is where the next person looks. --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 128b046..d1ab742 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,13 @@ on: # while the event pointed at a branch would be attested to the wrong commit. # The ref picker only offers refs that exist, and `ref_type` below rejects # everything that is not a tag. + # + # The consequence, which is the price of the above: a dispatch runs the + # workflow file stored in the selected tag, so this only rescues tags created + # from this commit onwards. A tag older than this file has no dispatch trigger + # to select and cannot be recovered here — delete it along with its GitHub + # release and let the next release supersede it, since a tag that never + # reached npm has nothing depending on it. workflow_dispatch: permissions: From 0fb8edf3d504c408061dfd0508709f1200e82c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 22:02:29 -0300 Subject: [PATCH 6/9] fix(ci): serialize releases and prove a dispatch targets a release tag Three holes in the recovery path, all reachable only through it. The concurrency group keyed on `github.ref`, which differs between a release from main and a recovery on a tag, so both could reach npm publish at once and race over `latest`. One group for every run instead. `ref_type == 'tag'` proves the ref is a tag, not that it is a release: anyone able to push a tag could aim the job holding OIDC rights at arbitrary code and skip main. A dispatch now has to name a tag that carries a GitHub release, which is what release-please creates and a hand-pushed tag lacks, and whose version matches the tree. npm refuses `latest` implicitly once a higher version is published, so a recovery attempted after a newer release would have failed at the publish step, past the whole build. It now fails in the first seconds and says why. --- .github/workflows/release.yml | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1ab742..bf582a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,12 @@ on: permissions: contents: read +# One group for every release and recovery run, not one per ref: a recovery +# dispatched on a tag has a different `github.ref` than a release from main, so +# keying on it would let the two reach `npm publish` at the same time and race +# over which one `latest` ends up pointing at. concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false env: @@ -121,6 +125,39 @@ jobs: ref: ${{ github.sha }} persist-credentials: false + # `ref_type == 'tag'` only proves the ref is a tag, not that it is a + # release. Without this, anyone able to push a tag could point the job + # holding npm OIDC rights at arbitrary code, bypassing main entirely. + # A GitHub release is what release-please creates and what a hand-pushed + # tag does not have; the version match then ties the tag to the tree. + - name: Confirm the dispatch targets a release tag + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::$TAG has no GitHub release; only release tags can be published" + exit 1 + fi + + version=$(jq -r .version package.json) + if [ "$TAG" != "v$version" ]; then + echo "::error::$TAG does not match package.json version $version" + exit 1 + fi + + # npm refuses to apply `latest` implicitly once the registry holds a + # higher version, so a recovery attempted after a newer release would + # fail at the publish step — after the whole build. Say so here. + name=$(jq -r .name package.json) + latest=$(npm view "$name" version 2>/dev/null || true) + if [ -n "$latest" ] && [ "$latest" != "$version" ] && + [ "$(printf '%s\n%s\n' "$latest" "$version" | sort -V | tail -1)" = "$latest" ]; then + echo "::error::$name@$latest is already published; $version cannot take latest implicitly" + exit 1 + fi + # Guards the assumption on the push path: release-please tags the commit # it just merged, so a tag pointing elsewhere means this run would verify # and publish something the release does not name. From adf85df7f3561cac250fcea8463faa1c28147ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 22:04:34 -0300 Subject: [PATCH 7/9] ci: build the commit the release was tagged at on the push path The event sha and the release commit diverge when the run for a merged release PR fails or is superseded: a later push creates the release, and the tag names the earlier merge. Asserting equality against the event would then fail after the GitHub release exists, leaving that version unpublishable. The action reports the commit it tagged, so use it where it exists and fall back to the event sha on the dispatch path, where the event already points at the tag. --- .github/workflows/release.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf582a0..75ad811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,11 @@ jobs: outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} + # The commit the release was tagged at. Not necessarily the commit that + # triggered this run: if the run for a release PR merge fails or is + # superseded, a later push is what creates the release, and the tag then + # names the earlier merge. + sha: ${{ steps.release.outputs.sha }} steps: # A pull request opened with GITHUB_TOKEN gets its checks queued in an # approval-required state, so the release PR sits there until a maintainer @@ -118,11 +123,12 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # The commit the event recorded, never a ref. A ref can move between - # the event and either checkout, and npm records this sha as the - # package's source regardless of what the workspace holds — so - # resolving a tag here could publish one tree while attesting another. - ref: ${{ github.sha }} + # A sha, never a ref: a ref can move between this checkout and + # publish's, leaving the two jobs on different trees. On the push path + # that is the commit the release was tagged at, which is not always + # the one that triggered the run; on dispatch the event's sha is the + # tag's own commit. + ref: ${{ needs.release-please.outputs.sha || github.sha }} persist-credentials: false # `ref_type == 'tag'` only proves the ref is a tag, not that it is a @@ -166,14 +172,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.release-please.outputs.tag_name }} + RELEASE_SHA: ${{ needs.release-please.outputs.sha }} run: | ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") target=$(jq -r '.object.sha' <<<"$ref") if [ "$(jq -r '.object.type' <<<"$ref")" = tag ]; then target=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$target" --jq '.object.sha') fi - if [ "$target" != "$GITHUB_SHA" ]; then - echo "::error::$TAG points at $target, not $GITHUB_SHA" + if [ "$target" != "$RELEASE_SHA" ]; then + echo "::error::$TAG points at $target, not $RELEASE_SHA" exit 1 fi @@ -236,8 +243,10 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Same immutable commit verify checked out and vouched for. - ref: ${{ github.sha }} + # Same immutable commit verify checked out and vouched for. Pinned by + # sha so retargeting the tag while this job waits on the environment + # approval cannot change what gets published. + ref: ${{ needs.release-please.outputs.sha || github.sha }} # Nothing here talks to the remote after checkout, and the steps below # run cargo, bun and third-party build scripts in a job holding OIDC # publish rights. Don't leave the token in .git/config for them. From a06a60316e6ce55e093dd01f801e614a573e51df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 22:13:05 -0300 Subject: [PATCH 8/9] fix(ci): refuse a release whose commit is not the event's, and pin protoc npm attests the package to the event's sha whatever the workspace holds. Checking out the commit the action tagged fixed the tree but moved the mismatch onto provenance: a release created by a later push than the merge it names would ship a tarball attested to a commit that was never built. Neither choice is safe on the push path, so refuse there and say what to do: dispatching on the tag publishes the same release with the event pointing at it, which is the one shape where tree and attestation agree. protoc was also the last unpinned tool on this path. Verify and publish install it separately and publish can start much later, behind the environment gate, so a new default between them would test one generated codec and ship another. --- .github/workflows/release.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75ad811..fc0e942 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,6 +174,12 @@ jobs: TAG: ${{ needs.release-please.outputs.tag_name }} RELEASE_SHA: ${{ needs.release-please.outputs.sha }} run: | + if [ "$RELEASE_SHA" != "$GITHUB_SHA" ]; then + echo "::error::$TAG was tagged at $RELEASE_SHA but this event is $GITHUB_SHA;" + echo "::error::provenance would name the wrong commit. Dispatch this workflow on $TAG instead." + exit 1 + fi + ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") target=$(jq -r '.object.sha' <<<"$ref") if [ "$(jq -r '.object.type' <<<"$ref")" = tag ]; then @@ -198,7 +204,11 @@ jobs: with: # protoc: `bun run build` regenerates the proto codec, which ci.yml # never exercises because it calls wasm-pack and build:ts directly. - tool: wasm-pack,protoc + # protoc pinned like bun and npm: verify and publish each install it, and + # publish can start much later behind the environment gate, so an + # unpinned default could generate one codec for the tests and another + # for the tarball. + tool: wasm-pack,protoc@35.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -266,7 +276,11 @@ jobs: with: # protoc: `bun run build` regenerates the proto codec, which ci.yml # never exercises because it calls wasm-pack and build:ts directly. - tool: wasm-pack,protoc + # protoc pinned like bun and npm: verify and publish each install it, and + # publish can start much later behind the environment gate, so an + # unpinned default could generate one codec for the tests and another + # for the tarball. + tool: wasm-pack,protoc@35.1 # Pinned, unlike ci.yml: this bun builds the wasm artifact that ships to # npm, so a floating version would change the published bytes without a From be326dba2e02eb0a8fba6618965d549435b42853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 22:21:53 -0300 Subject: [PATCH 9/9] fix(ci): bind a recovery dispatch to a commit that reached main A GitHub release proves nothing about where the code came from: anyone with write access can tag arbitrary unmerged code, set a matching version in that tree, attach a release record by hand, and dispatch. Both checks would pass and the code would run in the job holding npm OIDC rights, which is exactly the protected path being bypassed. Reachability from main is what the release record was standing in for. Check it first, and let the release and version checks narrow from there. --- .github/workflows/release.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc0e942..05ad12c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,19 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.ref_name }} run: | + # A GitHub release proves nothing on its own: anyone with write access + # can tag arbitrary code and attach a release record to it by hand. + # Reachability from main is what ties this commit to the protected + # branch it had to pass through, so check that first. + status=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$GITHUB_SHA" --jq .status) + case "$status" in + identical | behind) ;; + *) + echo "::error::$TAG is at a commit that is not on main ($status)" + exit 1 + ;; + esac + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "::error::$TAG has no GitHub release; only release tags can be published" exit 1