From ab41678208f4b47393ad76fe922cf0ccee21d260 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 3 Aug 2026 13:46:31 +0300 Subject: [PATCH 1/2] ci: add release.yml so the npm trusted-publisher binding becomes live The trusted publisher for this package is already configured on npm and names `--file release.yml --env npm-release --allow-publish`. Measured 2026-08-03, that file returned 404 in this repository, so the binding named a workflow that never ran and therefore never matched. Publishing has stayed on hand-run workstation tokens. This adds the missing workflow. The filename and the `npm-release` environment are fixed by the npm-side configuration and cannot be renamed without redoing the trust setup. Authentication is OIDC only: the job requests `id-token: write` and the workflow carries no npm token of any kind. npm exchanges the Actions id-token for a short-lived publish credential, which is the migration path off the 2FA-bypass tokens npm removes in January 2027. Provenance is generated automatically by npm under trusted publishing. That holds only because this repository is public: npm does not generate provenance for private repositories even when the package is public. Two gates fail closed before any work is done: the tag must agree with package.json, and the version must not already exist on the registry, since npm versions are immutable. `workflow_dispatch` with a `dry_run` input defaulting to true runs every gate and the OIDC mint without publishing, so the wiring can be proven before a real release depends on it. Agent: Vespasian --- .github/workflows/release.yml | 161 ++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..886474d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,161 @@ +name: Release npm package + +# npm trusted publishing is bound to this workflow by FILENAME (release.yml) +# and to the `npm-release` environment declared on the job below. Both strings +# are already recorded on npm's side as the trusted-publisher configuration for +# @hasna/knowledge. They are NOT free choices: renaming either one silently +# de-authorises publishing, because the binding stops matching, and the failure +# surfaces as an auth error that never mentions the rename. Change them only +# together with `npm trust`. +# +# Provenance is generated automatically by npm under trusted publishing, with +# one condition that is easy to miss: npm does NOT generate provenance for +# PRIVATE repositories, even when the package itself is public. hasna/knowledge is +# public (measured), so it holds here. If this repository is ever made private, +# provenance stops being produced and this workflow will not tell you. + +on: + push: + tags: + - "npm/knowledge/v*" + # A dry run exercises every gate and the OIDC token mint without publishing. + # This workflow has never run, so there is a way to prove the wiring before a + # real release depends on it. + workflow_dispatch: + inputs: + dry_run: + description: "Run every gate but do not publish" + type: boolean + default: true + +concurrency: + group: hasna-knowledge-npm-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + # Never publish from a fork that inherited this workflow. + if: github.repository == 'hasna/knowledge' + runs-on: ubuntu-latest + environment: npm-release + timeout-minutes: 30 + permissions: + contents: read + # Mints the OIDC token npm exchanges for a short-lived publish + # credential. Without it there is no token at all and no fallback: this + # workflow deliberately carries no npm token of any kind. + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.18.0" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.14" + + # Publishing runs through npm, not bun: bun publish has no OIDC trusted + # publishing support, so it cannot authenticate here at all. + - name: Verify npm supports trusted publishing + run: | + set -euo pipefail + have="$(npm --version)" + need="11.5.1" + if [ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -n1)" != "$need" ]; then + echo "::error::npm ${have} is older than ${need}, so OIDC trusted publishing is unavailable. Raise node-version until its bundled npm meets the minimum." + exit 1 + fi + echo "npm ${have} meets the ${need} minimum for trusted publishing" + + - name: Install locked dependencies with release-age quarantine + run: bun install --frozen-lockfile --minimum-release-age 604800 + + - name: Bind the tag to the package version + id: version + run: | + set -euo pipefail + pkg_name="$(node -p "require('./package.json').name")" + pkg_version="$(node -p "require('./package.json').version")" + echo "name=${pkg_name}" >> "$GITHUB_OUTPUT" + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + if [ "${GITHUB_REF_TYPE}" != "tag" ]; then + echo "manual run: ${pkg_name}@${pkg_version} from ${GITHUB_REF_NAME}" + exit 0 + fi + case "${GITHUB_REF_NAME}" in + npm/knowledge/v*) + tag_version="${GITHUB_REF_NAME#npm/knowledge/v}" ;; + *) + echo "::error::tag ${GITHUB_REF_NAME} does not match a recognised release tag prefix" + exit 1 ;; + esac + if [ "${tag_version}" != "${pkg_version}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} carries version ${tag_version} but package.json declares ${pkg_version}" + exit 1 + fi + echo "tag ${GITHUB_REF_NAME} agrees with package.json ${pkg_version}" + + # npm versions are immutable, so a version that already exists can never + # be replaced by this run. Failing here names that plainly instead of + # letting the publish step report it after the whole suite has run. + - name: Reject an already published version + run: | + set -euo pipefail + name="${{ steps.version.outputs.name }}" + version="${{ steps.version.outputs.version }}" + if npm view "${name}@${version}" version >/dev/null 2>&1; then + echo "::error::${name}@${version} is already published and npm versions are immutable. Bump the version." + exit 1 + fi + echo "${name}@${version} is not yet published" + + # No typecheck step: this repository declares no `typecheck` script, and a + # gate that cannot pass is worse than a missing one. Adding the script is + # separate work with its own review. + + - name: Test + run: bun test + + - name: Build + run: bun run build + + # No NODE_AUTH_TOKEN, and no token of any kind. npm detects the Actions + # OIDC environment and exchanges the id-token for a short-lived, + # publish-scoped credential. --provenance is passed explicitly: npm + # documents provenance as automatic under trusted publishing, but that + # has been reported not to hold in practice, and passing the flag is a + # no-op when it is already automatic. + - name: Publish to npm via OIDC trusted publishing + if: github.event_name == 'push' || inputs.dry_run == false + run: npm publish --provenance --access public + + - name: Verify the published version from the registry + if: github.event_name == 'push' || inputs.dry_run == false + run: | + set -euo pipefail + name="${{ steps.version.outputs.name }}" + version="${{ steps.version.outputs.version }}" + for attempt in 1 2 3 4 5; do + if resolved="$(npm view "${name}@${version}" version 2>/dev/null)"; then + if [ "${resolved}" = "${version}" ]; then + echo "registry serves ${name}@${resolved}" + exit 0 + fi + echo "::error::registry resolved ${name}@${version} to ${resolved}" + exit 1 + fi + echo "attempt ${attempt}: ${name}@${version} not visible yet, waiting" + sleep 10 + done + echo "::error::${name}@${version} did not become visible on the registry after publish" + exit 1 From 39dd7e442edd036c7060263b9057e7539b179913 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 3 Aug 2026 14:30:24 +0300 Subject: [PATCH 2/2] fix(ci): close the dispatch publish path and the false dry-run claim workflow_dispatch carried a dry_run input while the publish step fired on "github.event_name == 'push' || inputs.dry_run == false". A manual run from any branch therefore published whatever that branch's package.json declared, with no tag binding at all: the version step aborts its own check on a non-tag ref and exits 0, while publish still ran. That routed around the protect-main ruleset entirely. The input is removed, so publish and the registry verification are now gated on github.event_name == 'push' alone. The trigger comment claimed a dry run exercised the OIDC token mint. npm exchanges the id-token during publish, which is the one step a dry run skipped, so the claim was false in the direction that matters: an operator saw green and concluded the trust binding was proven while nothing had touched npm. Rewritten to state what a manual run does and does not show. Adds the guard requiring a release commit to be contained in protected main, so a tag on an unmerged branch cannot publish different code. Drops --minimum-release-age from the frozen-lockfile install and renames the step. A frozen lockfile performs no resolution, so the age filter has nothing to filter: --frozen-lockfile --minimum-release-age 999999999 exits 0, while the same command without --frozen-lockfile exits 1 with "blocked by minimum-release-age". The step name promised a quarantine it cannot deliver. Real quarantine belongs at lockfile-update time and is separate work. Ports hasna/conversations@fb621f97. Agent: Vespasian --- .github/workflows/release.yml | 40 ++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 886474d..746cbd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,15 +18,10 @@ on: push: tags: - "npm/knowledge/v*" - # A dry run exercises every gate and the OIDC token mint without publishing. - # This workflow has never run, so there is a way to prove the wiring before a - # real release depends on it. + # A manual run exercises the repository gates without publishing. npm only + # exchanges the OIDC token during a publish or stage operation, so this does + # not claim to validate the npm-side trusted-publisher binding. workflow_dispatch: - inputs: - dry_run: - description: "Run every gate but do not publish" - type: boolean - default: true concurrency: group: hasna-knowledge-npm-release @@ -77,8 +72,15 @@ jobs: fi echo "npm ${have} meets the ${need} minimum for trusted publishing" - - name: Install locked dependencies with release-age quarantine - run: bun install --frozen-lockfile --minimum-release-age 604800 + # --minimum-release-age is deliberately NOT passed here. A frozen lockfile + # performs no resolution, so the age filter has nothing left to filter and + # the flag is a measured no-op: `bun install --frozen-lockfile + # --minimum-release-age 999999999` exits 0, while the same command without + # --frozen-lockfile exits 1 with "blocked by minimum-release-age". The + # quarantine is real, but it belongs at lockfile-UPDATE time; naming it + # here promised a protection this step cannot deliver. + - name: Install locked dependencies + run: bun install --frozen-lockfile - name: Bind the tag to the package version id: version @@ -105,10 +107,24 @@ jobs: fi echo "tag ${GITHUB_REF_NAME} agrees with package.json ${pkg_version}" + # Tags are not protected by this repository's main-branch ruleset. Bind + # the release to reviewed history in the workflow itself so a tag on an + # unmerged branch cannot publish different code. + - name: Require the release commit on protected main + if: github.event_name == 'push' + run: | + set -euo pipefail + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "refs/remotes/origin/main"; then + echo "::error::release commit ${GITHUB_SHA} is not contained in protected main" + exit 1 + fi + echo "release commit ${GITHUB_SHA} is contained in protected main" + # npm versions are immutable, so a version that already exists can never # be replaced by this run. Failing here names that plainly instead of # letting the publish step report it after the whole suite has run. - name: Reject an already published version + if: github.event_name == 'push' run: | set -euo pipefail name="${{ steps.version.outputs.name }}" @@ -136,11 +152,11 @@ jobs: # has been reported not to hold in practice, and passing the flag is a # no-op when it is already automatic. - name: Publish to npm via OIDC trusted publishing - if: github.event_name == 'push' || inputs.dry_run == false + if: github.event_name == 'push' run: npm publish --provenance --access public - name: Verify the published version from the registry - if: github.event_name == 'push' || inputs.dry_run == false + if: github.event_name == 'push' run: | set -euo pipefail name="${{ steps.version.outputs.name }}"