Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/release-dry-run.yml
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<semver> 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
43 changes: 43 additions & 0 deletions scripts/release-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading