diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfd4da4..259756c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,10 @@ name: CI on: + # No paths filter on push: release.yml requires a push run of this workflow on + # the tagged commit, and a docs-only commit (a changelog fix) must still get one. push: branches: [master, main] - paths: - - 'packages/**' - - 'conformance/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - 'turbo.json' - - 'tsconfig.base.json' - - '.github/workflows/ci.yml' pull_request: branches: [master, main] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64fc9ae..3254c23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,127 @@ name: Release +# Publishes the five @guardcore npm packages for a release tag. Nothing is +# linted or tested here: the tagged commit must already have passed the push +# workflows listed in REQUIRED_WORKFLOWS (ci.yml runs lint, the node 18/20/22 +# tests, coverage, conformance and the build; ecosystem-gate.yml and +# live-smoke.yml run the monorepo and the dockerized example). A release can +# therefore never fail on a check its pull request did not run. +# +# Re-drive: run this workflow by hand (Actions > Release > Run workflow) with +# the tag, for example v4.4.0. It checks out that tag, so a fixed workflow on +# master can publish an existing tag. A package version already on the npm +# registry is skipped. + on: release: types: [created] workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish, for example v4.4.0' + required: true + +permissions: {} + +concurrency: + group: release-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false +env: + REQUIRED_WORKFLOWS: >- + .github/workflows/ci.yml + .github/workflows/ecosystem-gate.yml + .github/workflows/live-smoke.yml jobs: + verify: + name: Tagged commit passed CI + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Resolve the tag + id: tag + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + tag="${INPUT_TAG:-$RELEASE_TAG}" + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::tag '$tag' is not of the form v1.2.3" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Checkout the tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ steps.tag.outputs.tag }} + persist-credentials: false + + - name: Verify the tag equals the package versions + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + for pkg in core express fastify nestjs hono; do + version=$(jq -r .version "packages/$pkg/package.json") + if [ "${TAG#v}" != "$version" ]; then + echo "::error::tag $TAG does not match packages/$pkg version $version" + exit 1 + fi + echo "ok: $TAG matches packages/$pkg $version" + done + + # The newest push run of each required workflow on the tagged commit + # must have succeeded. A release created right after a merge waits for + # them (up to an hour) instead of failing. + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + sha=$(git rev-parse HEAD) + for _ in $(seq 1 60); do + runs=$(gh api "repos/$REPO/actions/runs?head_sha=$sha&event=push&per_page=100" \ + --jq '[.workflow_runs | group_by(.path)[] | max_by(.run_number) | {path, status, conclusion}]') + waiting=0 + for wf in $REQUIRED_WORKFLOWS; do + run=$(jq -c --arg p "$wf" '.[] | select(.path == $p)' <<< "$runs") + if [ -z "$run" ]; then + echo "$wf: no push run on $sha yet"; waiting=1; continue + fi + status=$(jq -r .status <<< "$run"); conclusion=$(jq -r .conclusion <<< "$run") + if [ "$status" != "completed" ]; then + echo "$wf: $status"; waiting=1 + elif [ "$conclusion" != "success" ]; then + echo "::error::$wf concluded $conclusion on $sha; fix master and release a new tag" + exit 1 + else + echo "ok: $wf success on $sha" + fi + done + [ "$waiting" = "0" ] && exit 0 + sleep 60 + done + echo "::error::required workflows did not finish on $sha within an hour" + exit 1 + publish: + name: Publish to npm (trusted publishing) + needs: verify runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - - uses: actions/checkout@v6 + - name: Checkout the tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.verify.outputs.tag }} + persist-credentials: false + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: @@ -26,9 +135,9 @@ jobs: npm --version - run: pnpm build - # Version-bump only the packages that ship in this release; a package - # whose version is already on the npm registry is skipped by the - # per-package check below (the registry rejects duplicate versions). + # A package whose version is already on the npm registry is skipped by + # the per-package check below (the registry rejects duplicate versions), + # so a re-drive after a partial failure is safe. - name: Resolve versions to publish id: versions run: | diff --git a/AGENTS.md b/AGENTS.md index 3bb0c67..123e0ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ What `@guardcore/core` provides: - Adapters depend on core with `@guardcore/core: workspace:*`, so core must be built before adapters (turbo enforces this with `dependsOn: ["^build"]`) - This repo ports guard-core (https://github.com/Guard-Core/guard-core), the Python engine; when in doubt about intended behavior, the Python project is the upstream reference -- Publishing is lock-step: all packages carry the same version, bumped together with `make bump-version VERSION=x.y.z`; a GitHub Release triggers .github/workflows/release.yml, which publishes each package to npm with provenance +- Publishing is lock-step: all packages carry the same version, bumped together with `make bump-version VERSION=x.y.z`; a GitHub Release triggers .github/workflows/release.yml, which publishes each package to npm with provenance. The release workflow runs no lint or test of its own: it requires the tagged commit's push runs of ci.yml, ecosystem-gate.yml and live-smoke.yml to have passed (waiting up to an hour), then publishes each package not already on npm. To re-drive an existing tag (for example after a partial failure), run Actions > Release > Run workflow with the tag input. ## Boundary Rules diff --git a/CLAUDE.md b/CLAUDE.md index 12869f4..56a811c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ What `@guardcore/core` provides: - Adapters depend on core with `@guardcore/core: workspace:*`, so core must be built before adapters (turbo enforces this with `dependsOn: ["^build"]`) - This repo ports guard-core (https://github.com/Guard-Core/guard-core), the Python engine; when in doubt about intended behavior, the Python project is the upstream reference -- Publishing is lock-step: all packages carry the same version, bumped together with `make bump-version VERSION=x.y.z`; a GitHub Release triggers .github/workflows/release.yml, which publishes each package to npm with provenance +- Publishing is lock-step: all packages carry the same version, bumped together with `make bump-version VERSION=x.y.z`; a GitHub Release triggers .github/workflows/release.yml, which publishes each package to npm with provenance. The release workflow runs no lint or test of its own: it requires the tagged commit's push runs of ci.yml, ecosystem-gate.yml and live-smoke.yml to have passed (waiting up to an hour), then publishes each package not already on npm. To re-drive an existing tag (for example after a partial failure), run Actions > Release > Run workflow with the tag input. ## Boundary Rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 756c2b0..96bdc4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,4 +109,4 @@ Use `make bump-version VERSION=x.y.z` to update all package versions simultaneou 4. Update CHANGELOG.md 5. Create a PR with the version bump 6. After merge, create a GitHub Release with the tag `vx.y.z` -7. The release workflow automatically publishes all packages to npm +7. The release workflow automatically publishes all packages to npm once the tagged commit's push CI (ci.yml, ecosystem-gate.yml, live-smoke.yml) has passed, skipping versions already on npm. To re-drive an existing tag, run the Release workflow by hand with the `tag` input.