diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..d971caf --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,91 @@ +name: npm publish + +# A v* tag publishes @heightproof/shared, sdk-node and web3 at that version, in dependency order. +# +# Authentication is npm trusted publishing (OIDC): no npm token is stored in GitHub, and every +# release carries a provenance attestation linking it to this workflow run. Each package must +# list this repository and workflow as a trusted publisher on npmjs.com — see RELEASING.md. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + dry_run: + description: "Pack and check without publishing" + type: boolean + default: true + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + # Exchanged for a short-lived npm credential, and used to sign provenance. + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + + # Trusted publishing needs npm 11.5.1 or later; Node 22 bundles npm 10. + - name: Upgrade npm + run: npm install -g npm@11.21.1 + + - name: Install + run: npm ci --include=optional + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Check versions match the tag + if: github.event_name == 'push' + run: | + tag="${GITHUB_REF_NAME#v}" + for pkg in shared sdk-node web3; do + version="$(node -p "require('./packages/$pkg/package.json').version")" + if [ "$version" != "$tag" ]; then + echo "::error::packages/$pkg is $version but the tag is v$tag" + exit 1 + fi + # Internal dependencies are pinned exactly, so a stale pin would publish a package + # that installs the previous release of its siblings. + stale="$(node -p "Object.entries(require('./packages/$pkg/package.json').dependencies ?? {}).filter(([n, v]) => n.startsWith('@heightproof/') && v !== '$tag').map(([n, v]) => n + '@' + v).join(' ')")" + if [ -n "$stale" ]; then + echo "::error::packages/$pkg pins $stale, expected $tag" + exit 1 + fi + done + + - name: Publish + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + run: | + for pkg in shared sdk-node web3; do + name="$(node -p "require('./packages/$pkg/package.json').name")" + version="$(node -p "require('./packages/$pkg/package.json').version")" + + # Re-running a release must not fail on packages it already published. + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version is already on npm, skipping" + continue + fi + + if [ "$DRY_RUN" = "true" ]; then + npm publish -w "packages/$pkg" --access public --dry-run + else + npm publish -w "packages/$pkg" --access public --provenance + fi + done diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..6e507c3 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,64 @@ +# Releasing + +A `v*` tag releases everything at that version: + +- `.github/workflows/release.yml` publishes the container images and the Helm chart to GHCR. +- `.github/workflows/npm-publish.yml` publishes `@heightproof/shared`, `@heightproof/sdk-node` + and `@heightproof/web3` to npm, in that order, with provenance. + +## Cutting a release + +1. Set the version in all three packages, and in the exact pins they hold on each other: + + ```bash + VERSION=0.2.0 + npm version "$VERSION" --no-git-tag-version -w packages/shared -w packages/sdk-node -w packages/web3 + npm pkg set "dependencies.@heightproof/shared=$VERSION" -w packages/sdk-node -w packages/web3 + npm pkg set "dependencies.@heightproof/sdk-node=$VERSION" -w packages/web3 + npm install + ``` + +2. Add the release to `CHANGELOG.md`, then open a PR and merge it once CI is green. +3. Tag the merge commit on `main` and push the tag: + + ```bash + git switch main && git pull + git tag "v$VERSION" && git push origin "v$VERSION" + ``` + +The workflow refuses to publish when a package version or an internal pin does not match the +tag. It skips any package already published at that version, so a failed run can be re-run. + +To check a release without publishing it, run **npm publish** from the Actions tab with +`dry_run` checked. + +## One-time setup: trusted publishing + +The workflow authenticates with npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) +(OIDC), so no npm token is stored in GitHub. npm only lets you add a trusted publisher to a package +that already exists, so: + +1. Publish the first version by hand, from a clean checkout of `main`, as an owner of the + `heightproof` npm organization: + + ```bash + npm login + npm run build + npm publish -w packages/shared --access public + npm publish -w packages/sdk-node --access public + npm publish -w packages/web3 --access public + ``` + +2. On npmjs.com, for each of the three packages, go to **Settings → Trusted publishing** and add + GitHub Actions with: + + | Field | Value | + | ------------ | ----------------- | + | Organization | `macjayz` | + | Repository | `heightproof` | + | Workflow | `npm-publish.yml` | + +3. Optionally, under **Settings → Publishing access** for each package, require 2FA and disallow + tokens. Trusted publishing keeps working, and nobody can publish with a leaked token. + +Every release after that is just a tag.