diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d358c67 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,121 @@ +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 + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: publish ${{ inputs.tag || github.ref_name }} + runs-on: ubuntu-latest + permissions: + contents: read + 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. + 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="${INPUT_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: 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 + # 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 + # 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 + # that `id-token: write` mints. Never commit a token. + run: npm publish --provenance --access public + 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: + GH_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7aee2f5..d299a4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,54 @@ 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. 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. + +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 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 +(`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