diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 0000000..3c6b0ee --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,57 @@ +name: Dry-run npm release gates + +# This workflow is intentionally separate from release.yml. npm trusts the +# release.yml filename, so a branch-selectable manual trigger must not share its +# environment or OIDC authority. This lane proves the package gates only. +on: + workflow_dispatch: + +concurrency: + group: hasna-instructions-npm-release-dry-run + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + if: github.repository == 'hasna/instructions' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.18.0" + package-manager-cache: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.14" + + - name: Verify npm supports trusted publishing + run: | + set -euo pipefail + have="$(npm --version)" + need="11.5.1" + if [ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -n1)" != "$need" ]; then + echo "::error::npm ${have} is older than ${need}, so OIDC trusted publishing is unavailable. Raise node-version until its bundled npm meets the minimum." + exit 1 + fi + echo "npm ${have} meets the ${need} minimum for trusted publishing" + + - name: Install locked dependencies with release-age quarantine + run: bun install --frozen-lockfile --minimum-release-age 604800 + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test + + - name: Build + run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..049c6a0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,181 @@ +name: Release npm package + +# npm trusted publishing is bound to this workflow by FILENAME (release.yml) +# and to the `npm-release` environment declared on the job below. Both strings +# are already recorded on npm's side as the trusted-publisher configuration for +# @hasna/instructions. They are NOT free choices: renaming either one silently +# de-authorises publishing, because the binding stops matching, and the failure +# surfaces as an auth error that never mentions the rename. Change them only +# together with `npm trust`. +# +# Provenance is generated automatically by npm under trusted publishing, with +# one condition that is easy to miss: npm does NOT generate provenance for +# PRIVATE repositories, even when the package itself is public. hasna/instructions is +# public (measured), so it holds here. If this repository is ever made private, +# provenance stops being produced and this workflow will not tell you. + +on: + # repository_dispatch runs the workflow definition from protected `main`, not + # from the tag whose package contents will be published. A tag-push or manual + # branch dispatch would let that unreviewed ref rewrite its own release checks + # while retaining this workflow filename and npm environment binding. + repository_dispatch: + types: [npm-release] + +concurrency: + group: hasna-instructions-npm-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + # Never publish from a fork that inherited this workflow. + if: github.repository == 'hasna/instructions' + runs-on: ubuntu-latest + environment: npm-release + timeout-minutes: 30 + permissions: + contents: read + # Mints the OIDC token npm exchanges for a short-lived publish + # credential. Without it there is no token at all and no fallback: this + # workflow deliberately carries no npm token of any kind. + id-token: write + steps: + - name: Validate release request + id: request + env: + RELEASE_TAG: ${{ github.event.client_payload.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^npm/instructions/v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::release dispatch requires client_payload.tag in npm/instructions/v form" + exit 1 + fi + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.request.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify release commit is on protected main + env: + RELEASE_TAG: ${{ steps.request.outputs.tag }} + run: | + set -euo pipefail + if ! git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + echo "::error::release tag ${RELEASE_TAG} does not exist" + exit 1 + fi + release_sha="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + checkout_sha="$(git rev-parse HEAD)" + if [ "${checkout_sha}" != "${release_sha}" ]; then + echo "::error::checked out ${checkout_sha}, but tag ${RELEASE_TAG} resolves to ${release_sha}" + exit 1 + fi + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if ! git merge-base --is-ancestor "${release_sha}" refs/remotes/origin/main; then + echo "::error::tag ${RELEASE_TAG} points to ${release_sha}, which is not in protected main history" + exit 1 + fi + echo "tag ${RELEASE_TAG} resolves to protected-main commit ${release_sha}" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.18.0" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.14" + + # Publishing runs through npm, not bun: bun publish has no OIDC trusted + # publishing support, so it cannot authenticate here at all. + - name: Verify npm supports trusted publishing + run: | + set -euo pipefail + have="$(npm --version)" + need="11.5.1" + if [ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -n1)" != "$need" ]; then + echo "::error::npm ${have} is older than ${need}, so OIDC trusted publishing is unavailable. Raise node-version until its bundled npm meets the minimum." + exit 1 + fi + echo "npm ${have} meets the ${need} minimum for trusted publishing" + + - name: Install locked dependencies with release-age quarantine + run: bun install --frozen-lockfile --minimum-release-age 604800 + + - name: Bind the tag to the package version + id: version + env: + RELEASE_TAG: ${{ steps.request.outputs.tag }} + run: | + set -euo pipefail + pkg_name="$(node -p "require('./package.json').name")" + pkg_version="$(node -p "require('./package.json').version")" + echo "name=${pkg_name}" >> "$GITHUB_OUTPUT" + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + tag_version="${RELEASE_TAG#npm/instructions/v}" + if [ "${tag_version}" != "${pkg_version}" ]; then + echo "::error::tag ${RELEASE_TAG} carries version ${tag_version} but package.json declares ${pkg_version}" + exit 1 + fi + echo "tag ${RELEASE_TAG} agrees with package.json ${pkg_version}" + + # npm versions are immutable, so a version that already exists can never + # be replaced by this run. Failing here names that plainly instead of + # letting the publish step report it after the whole suite has run. + - name: Reject an already published version + run: | + set -euo pipefail + name="${{ steps.version.outputs.name }}" + version="${{ steps.version.outputs.version }}" + if npm view "${name}@${version}" version >/dev/null 2>&1; then + echo "::error::${name}@${version} is already published and npm versions are immutable. Bump the version." + exit 1 + fi + echo "${name}@${version} is not yet published" + + # `bun test` does not invoke tsc in this repository, so typecheck is its + # own step rather than something the suite implies. + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test + + - name: Build + run: bun run build + + # No NODE_AUTH_TOKEN, and no token of any kind. npm detects the Actions + # OIDC environment and exchanges the id-token for a short-lived, + # publish-scoped credential. --provenance is passed explicitly: npm + # documents provenance as automatic under trusted publishing, but that + # has been reported not to hold in practice, and passing the flag is a + # no-op when it is already automatic. + - name: Publish to npm via OIDC trusted publishing + run: npm publish --provenance --access public + + - name: Verify the published version from the registry + run: | + set -euo pipefail + name="${{ steps.version.outputs.name }}" + version="${{ steps.version.outputs.version }}" + for attempt in 1 2 3 4 5; do + if resolved="$(npm view "${name}@${version}" version 2>/dev/null)"; then + if [ "${resolved}" = "${version}" ]; then + echo "registry serves ${name}@${resolved}" + exit 0 + fi + echo "::error::registry resolved ${name}@${version} to ${resolved}" + exit 1 + fi + echo "attempt ${attempt}: ${name}@${version} not visible yet, waiting" + sleep 10 + done + echo "::error::${name}@${version} did not become visible on the registry after publish" + exit 1 diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts new file mode 100644 index 0000000..631281b --- /dev/null +++ b/scripts/release-workflow.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const workflowsDir = join(import.meta.dir, "..", ".github", "workflows"); +const workflow = readFileSync(join(workflowsDir, "release.yml"), "utf-8"); +const dryRunPath = join(workflowsDir, "release-dry-run.yml"); +const dryRunWorkflow = existsSync(dryRunPath) ? readFileSync(dryRunPath, "utf-8") : ""; + +describe("npm release workflow authorization", () => { + test("the privileged workflow runs only from the default-branch dispatch surface", () => { + expect(workflow).toContain("repository_dispatch:"); + expect(workflow).toContain("types: [npm-release]"); + expect(workflow).not.toContain("workflow_dispatch:"); + expect(workflow).not.toMatch(/\n\s+push:/); + }); + + test("validates the requested tag before checkout and package execution", () => { + const validation = workflow.indexOf("- name: Validate release request"); + const checkout = workflow.indexOf("- uses: actions/checkout@"); + const install = workflow.indexOf("- name: Install locked dependencies"); + const ancestry = workflow.indexOf("git merge-base --is-ancestor"); + + expect(validation).toBeGreaterThan(-1); + expect(validation).toBeLessThan(checkout); + expect(workflow).toContain("git merge-base --is-ancestor"); + expect(workflow).toContain("refs/remotes/origin/main"); + expect(ancestry).toBeLessThan(install); + }); + + test("manual dry runs are isolated from trusted-publishing authority", () => { + expect(dryRunWorkflow).toContain("workflow_dispatch:"); + expect(dryRunWorkflow).not.toContain("environment: npm-release"); + expect(dryRunWorkflow).not.toContain("id-token: write"); + expect(dryRunWorkflow).not.toContain("npm publish"); + }); + + test("the manual dry run reaches every declared build gate", () => { + expect(dryRunWorkflow).toContain("run: bun run typecheck"); + expect(dryRunWorkflow).toContain("run: bun run test"); + expect(dryRunWorkflow).toContain("run: bun run build"); + }); +});