diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dbb4543 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +name: release + +# Stages the three packages on npm when a commit on `main` carries a version npm +# does not have yet and `security-audit` passed on that commit. Staging needs no +# 2FA and a staged package is not public: a maintainer approves each one on +# npmjs.com or with `npm stage approve`, which does need 2FA (PACKAGES.md -> +# Releasing). This workflow never publishes, and its trusted publisher on npm +# must not allow `npm publish`; a stolen workflow can then stage a version but +# cannot make it public. +# +# The file name is load-bearing: npm's trusted publisher for each package names +# `release.yml` and the `publish` environment, and rejects a token minted by +# any other workflow or environment. + +on: + push: + branches: [main] + workflow_dispatch: + +# Each job declares what it needs; nothing is granted at the top. +permissions: {} + +# One release at a time, and never cancel one that is mid-stage. +concurrency: + group: release + cancel-in-progress: false + +jobs: + plan: + name: plan + # A dispatch from another branch has no audit verdict to wait for. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # checkout + checks: read # the `security-audit` check run on this commit + outputs: + stage: ${{ steps.versions.outputs.stage }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # A package is staged when its version is not on npm. A version that is + # staged but not yet approved reads as unpublished here; staging it again + # fails loudly rather than replacing it. + - name: Find versions npm does not have + id: versions + shell: bash + run: | + set -euo pipefail + versions=() + missing=() + for dir in pgstencil auth stripe; do + name=$(jq -r .name "packages/$dir/package.json") + version=$(jq -r .version "packages/$dir/package.json") + versions+=("$version") + code=$(curl -s -o /dev/null -w '%{http_code}' \ + "https://registry.npmjs.org/${name/\//%2F}/$version") + case "$code" in + 200) echo "$name@$version is on npm" ;; + 404) echo "$name@$version is not on npm"; missing+=("$dir") ;; + *) echo "::error::npm answered HTTP $code for $name@$version"; exit 1 ;; + esac + done + if [ "$(printf '%s\n' "${versions[@]}" | sort -u | wc -l)" -ne 1 ]; then + echo "::error::The packages must share one version, found: ${versions[*]}" + exit 1 + fi + # Core first: auth and stripe peer on it, so a consumer can install + # each package as soon as it is approved. + echo "stage=${missing[*]:-}" >> "$GITHUB_OUTPUT" + + # `security-audit` runs on the same push and takes about twenty minutes; + # wait for its verdict on this commit. Any successful run counts, because a + # superseded run for the same commit may be cancelled. + - name: Require a passing security audit + if: steps.versions.outputs.stage != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + deadline=$((SECONDS + 3300)) + while true; do + states=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "security-audit") | (.conclusion // .status)] | join(" ")') + echo "security-audit on $GITHUB_SHA: ${states:-not started}" + case " $states " in + *" success "*) exit 0 ;; + *" in_progress "* | *" queued "* | *" waiting "* | *" pending "* | " ") ;; + *) echo "::error::security-audit did not pass on $GITHUB_SHA"; exit 1 ;; + esac + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::security-audit did not finish in time on $GITHUB_SHA" + exit 1 + fi + sleep 60 + done + + stage: + name: stage + needs: plan + if: needs.plan.outputs.stage != '' + runs-on: ubuntu-latest + timeout-minutes: 30 + # The npm trusted publisher names this environment. It admits only `main`. + environment: + name: publish + permissions: + contents: read # checkout + id-token: write # the OIDC token npm exchanges for a stage-only credential + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + + # Staging needs npm 11.15 or newer; trusted publishing needs 11.5.1. Node's + # bundled npm may be older, so install a pinned one only when it is. + - name: Use an npm that can stage + shell: bash + run: | + set -euo pipefail + need=11.15.0 + have=$(npm --version) + echo "runner npm: $have" + if [ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -1)" != "$need" ]; then + npm install --global npm@11.19.1 + echo "using npm $(npm --version)" + fi + + - run: pnpm install --frozen-lockfile + + # Pack exactly what `packages:verify` tests in `check.yml`, and stage those + # archives rather than repacking, so the staged bytes carry the commit that + # passed the audit in `dist/provenance.json`. + - name: Pack the packages + run: pnpm packages:pack + + - name: Stage the packages + shell: bash + env: + STAGE: ${{ needs.plan.outputs.stage }} + run: | + set -uo pipefail + failed=0 + { + echo "## Staged for approval" + echo + echo "Commit \`$GITHUB_SHA\`. Nothing is public until each package is approved with 2FA." + echo + } >> "$GITHUB_STEP_SUMMARY" + for dir in $STAGE; do + name=$(jq -r .name "packages/$dir/package.json") + version=$(jq -r .version "packages/$dir/package.json") + archive="dist/packages/$(echo "$name" | sed 's/^@//; s#/#-#')-$version.tgz" + echo "::group::npm stage publish $archive" + if npm stage publish "$archive" --access public; then + echo "- staged \`$name@$version\`" >> "$GITHUB_STEP_SUMMARY" + else + echo "::error::Could not stage $name@$version" + echo "- **failed** to stage \`$name@$version\`" >> "$GITHUB_STEP_SUMMARY" + failed=1 + fi + echo "::endgroup::" + done + { + echo + echo "Approve in this order, after checking each staged tarball's \`dist/provenance.json\` names \`$GITHUB_SHA\`:" + echo + echo '```sh' + echo 'npm stage list' + echo 'npm stage download # inspect' + echo 'npm stage approve # asks for 2FA' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$failed" diff --git a/PACKAGES.md b/PACKAGES.md index e8ce107..4cdb799 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -16,6 +16,22 @@ Applications declare pgstencil's peer dependencies themselves: `kysely` for ever Every other dependency is private: pgstencil owns its version and applications do not import it. Better Auth is deliberately private. pgstencil imports its internal subpaths and tests login and linking rules against specific releases, so its range admits patches only. A new login provider or Better Auth plugin belongs in `@pgstencil/auth`, not in an application. Private ranges start at the version pgstencil's CI tested. [`.github/renovate.json`](.github/renovate.json) raises that floor, and re-vendoring carries it into each application. +## Releasing + +The three packages share one version. A release is a pull request that raises `version` in all three `package.json` files: a patch for a fix or compatible addition, a minor for a breaking change, since these are 0.x. Nothing else triggers one. + +After that merges, [`release.yml`](.github/workflows/release.yml) finds any version npm does not have, waits for `security-audit` to pass on that commit, and **stages** the packages from the `publish` environment, which admits only `main`. A staged package is not public. Approve each one, core first because auth and billing peer on it: + +```sh +npm stage list +npm stage download # `dist/provenance.json` must name the audited commit +npm stage approve # asks for 2FA +``` + +Each package's trusted publisher on npmjs.com names this repository, `release.yml` and the `publish` environment, and leaves "can also publish directly with `npm publish`" unchecked, so the workflow can stage but never publish. Package access is set to require 2FA and disallow tokens. Applications receive an approved release as an ordinary Renovate update. Changes that are not released yet reach an application on a branch through `pnpm pgstencil:sync`. + +Version 0.2.0 was published by hand from commit `e79cc4d`, after its audit passed. + ## Application composition Production imports use `pgstencil`, `pgstencil/postgres`, `@pgstencil/auth` and `@pgstencil/stripe`. Docker startup and test fixtures are opt-in imports through `pgstencil/database`, `pgstencil/testing`, and `@pgstencil/stripe/testing`.