From 6856fce9aa79ca75972e4347859eb277bcafa75f Mon Sep 17 00:00:00 2001 From: Codebuff Date: Tue, 3 Nov 2026 08:08:59 +0000 Subject: [PATCH 1/2] ci: publish to npm from a v* tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package was only installable from git. A tag push now installs from the lockfile, checks, builds, publishes with provenance, and creates the release; a tag that disagrees with package.json fails before either of those run. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .github/workflows/release.yml | 87 +++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 33 +++++++++++++ 2 files changed, 120 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..b154d3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: Release + +# Publishing is a tag push, not a commit to main: `v0.1.0` and the version in +# package.json must agree before anything leaves the runner. +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: publish ${{ github.ref_name }} + runs-on: ubuntu-latest + permissions: + contents: write # gh release create + id-token: write # npm --provenance (sigstore attestation) + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Node 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Enable pnpm from packageManager + run: corepack enable + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Tag matches package.json + # Fails before the expensive steps: a tag that does not name the version + # in package.json would publish under the wrong number or refuse to + # publish at all, after check and build have already run. + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + version="$(node -p "require('./package.json').version")" + expected="v${version}" + echo "tag=${tag}" + echo "package.json version=${version}" + if [ "${tag}" != "${expected}" ]; then + echo "::error::tag ${tag} does not match package.json version (expected ${expected})" + exit 1 + fi + + - name: Check + run: pnpm check + + - name: Build + run: pnpm build + + - name: Verify the published surface + # Everything `files` ships, and the two entry points it must contain. + # `--dry-run` produces the tarball without uploading it; provenance is + # only attached by the real publish below. + run: | + set -euo pipefail + npm publish --dry-run --access public + npm pack --dry-run 2>&1 | tee /tmp/pack.txt + grep -q 'dist/index.js' /tmp/pack.txt + grep -q 'dist/index.d.ts' /tmp/pack.txt + grep -q 'LICENSE' /tmp/pack.txt + grep -q 'README.md' /tmp/pack.txt + + - name: Publish to npm + # NPM_TOKEN is a repository secret set by a maintainer. With npm trusted + # publishing configured for this repository instead, no token is needed + # and this env var is empty — npm then authenticates with the OIDC token + # that `id-token: write` mints. Never commit a token. + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub release + run: gh release create "${GITHUB_REF_NAME}" --verify-tag --generate-notes --title "${GITHUB_REF_NAME}" + env: + GH_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7aee2f5..9035033 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,39 @@ thresholds (80% lines and functions, 75% branches) are enforced by - By contributing you confirm you may license the change under Apache-2.0, and this project follows the [Code of Conduct](./CODE_OF_CONDUCT.md). +## Releasing + +Publishing is a tag push, not a manual `npm publish`. `.github/workflows/release.yml` +runs on any `v*` tag and does the whole release: install from the lockfile, +`pnpm check`, `pnpm build`, a `--dry-run` look at the tarball's contents, publish +with `--provenance`, then a GitHub release with generated notes. + +1. Bump `version` in `package.json` in a normal pull request and let CI go green. +2. Merge it, then tag the merge commit: `git tag v0.1.0 && git push origin v0.1.0`. +3. Watch the Release workflow. The tag **must** equal `v` + the `package.json` + version; a mismatch fails the job before `check` and `build` run, so a typo + costs a minute rather than a bad publish. Re-tag rather than editing a pushed + tag in place. + +Authentication is one of two setups, owned by a maintainer and never committed: + +- **Token:** add a granular npm token with publish rights as the repository + secret `NPM_TOKEN`. The workflow passes it to npm as `NODE_AUTH_TOKEN`. +- **Trusted publishing:** configure this repository and workflow in the package's + npm settings and leave `NPM_TOKEN` unset. `id-token: write` (already in the + workflow) lets npm mint an OIDC token instead. + +Either way `--provenance` attaches a signed link between the tarball and this +commit, which is why the workflow asks for `id-token: write`. Everything else +runs with `contents: read`, except the release step, which needs `contents: write` +to create the GitHub release. + +A version that has already been published cannot be reused: npm rejects the +upload, and the fix is a new version and a new tag. The package is unscoped +(`keygate-client`); if a name change is ever needed, agree on the scoped name in +an issue first, then change `name` in `package.json` and the install line in +[README.md](./README.md) together. + ## What this project will not add Account funding and friendbot, time bounds, fee bumping, transaction batching, a From fbd99709bab542e3cbb9eee8dc417b603227da94 Mon Sep 17 00:00:00 2001 From: Codebuff Date: Tue, 3 Nov 2026 08:24:19 +0000 Subject: [PATCH 2/2] ci: keep publish read-only and make the release rehearsable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release step needs contents: write, which the publish step should not have, so the two are separate jobs. A manual run now stops before upload, which makes the tag check and the tarball check testable without a token; npm is upgraded first because Node 20's npm 10 cannot do trusted publishing. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .github/workflows/release.yml | 42 +++++++++++++++++++++++++++++++---- CONTRIBUTING.md | 25 ++++++++++++++++----- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b154d3d..d358c67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,10 +2,20 @@ name: Release # Publishing is a tag push, not a commit to main: `v0.1.0` and the version in # package.json must agree before anything leaves the runner. +# +# A manual run does everything except publish and create the release, so the +# workflow can be rehearsed from a branch: Actions -> Release -> Run workflow, +# with `tag` set to the tag you intend to push. Only a real `v*` tag publishes. on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: Tag to check as if it had been pushed, e.g. v0.1.0 + required: true + type: string permissions: contents: read @@ -15,11 +25,11 @@ concurrency: cancel-in-progress: false jobs: - release: - name: publish ${{ github.ref_name }} + publish: + name: publish ${{ inputs.tag || github.ref_name }} runs-on: ubuntu-latest permissions: - contents: write # gh release create + contents: read id-token: write # npm --provenance (sigstore attestation) steps: - name: Checkout @@ -41,9 +51,12 @@ jobs: # Fails before the expensive steps: a tag that does not name the version # in package.json would publish under the wrong number or refuse to # publish at all, after check and build have already run. + env: + # Empty on a real tag push, where the tag is the ref that triggered us. + INPUT_TAG: ${{ inputs.tag }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" + tag="${INPUT_TAG:-${GITHUB_REF_NAME}}" version="$(node -p "require('./package.json').version")" expected="v${version}" echo "tag=${tag}" @@ -59,6 +72,13 @@ jobs: - name: Build run: pnpm build + - name: npm 11 for trusted publishing + # Node 20 bundles npm 10, and npm's OIDC publishing needs 11.5.1 or + # later. The NPM_TOKEN path works on npm 10 too; this keeps both open. + run: | + npm install -g 'npm@^11.5.1' + npm --version + - name: Verify the published surface # Everything `files` ships, and the two entry points it must contain. # `--dry-run` produces the tarball without uploading it; provenance is @@ -73,6 +93,8 @@ jobs: grep -q 'README.md' /tmp/pack.txt - name: Publish to npm + # A manual run stops here: only a tag push may upload. + if: ${{ github.event_name == 'push' }} # NPM_TOKEN is a repository secret set by a maintainer. With npm trusted # publishing configured for this repository instead, no token is needed # and this env var is empty — npm then authenticates with the OIDC token @@ -81,6 +103,18 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + release: + name: github release ${{ github.ref_name }} + needs: publish + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + # Separate job so the publish step above never holds write access. + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Create GitHub release run: gh release create "${GITHUB_REF_NAME}" --verify-tag --generate-notes --title "${GITHUB_REF_NAME}" env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9035033..d299a4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,8 +112,13 @@ runs on any `v*` tag and does the whole release: install from the lockfile, with `--provenance`, then a GitHub release with generated notes. 1. Bump `version` in `package.json` in a normal pull request and let CI go green. -2. Merge it, then tag the merge commit: `git tag v0.1.0 && git push origin v0.1.0`. -3. Watch the Release workflow. The tag **must** equal `v` + the `package.json` +2. Rehearse it if you want a log before tagging: Actions → **Release** → + _Run workflow_, with `tag` set to the tag you intend to push. A manual run + executes every step except the upload and the release, which are gated on + `github.event_name == 'push'` — a branch cannot publish, and both the matching + and mismatching tag cases can be checked this way. +3. Merge it, then tag the merge commit: `git tag v0.1.0 && git push origin v0.1.0`. +4. Watch the Release workflow. The tag **must** equal `v` + the `package.json` version; a mismatch fails the job before `check` and `build` run, so a typo costs a minute rather than a bad publish. Re-tag rather than editing a pushed tag in place. @@ -127,9 +132,19 @@ Authentication is one of two setups, owned by a maintainer and never committed: workflow) lets npm mint an OIDC token instead. Either way `--provenance` attaches a signed link between the tarball and this -commit, which is why the workflow asks for `id-token: write`. Everything else -runs with `contents: read`, except the release step, which needs `contents: write` -to create the GitHub release. +commit, which is why the publish job asks for `id-token: write`. Both jobs run +with `contents: read`; only the separate release job gets `contents: write`, so +the step that uploads to npm never holds write access to the repository. + +Two things that will otherwise fail at the last step: + +- Node 20 bundles npm 10, and npm's OIDC publishing needs npm 11.5.1 or later. The + workflow therefore upgrades npm before publishing; the `NPM_TOKEN` path would + work either way. +- Provenance is rejected by the registry (E422) unless `repository.url` in + `package.json` names the repository running the workflow. It does + (`TeethakingBuilds/keygate-client`), so releases must run there, not from a fork; + a fork needs its own package name and its own publish setup. A version that has already been published cannot be reused: npm rejects the upload, and the fix is a new version and a new tag. The package is unscoped