Skip to content
Draft
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
177 changes: 177 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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/knowledge. 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/knowledge 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:
push:
tags:
- "npm/knowledge/v*"
# A manual run exercises the repository gates without publishing. npm only
# exchanges the OIDC token during a publish or stage operation, so this does
# not claim to validate the npm-side trusted-publisher binding.
workflow_dispatch:

concurrency:
group: hasna-knowledge-npm-release
cancel-in-progress: false

permissions:
contents: read

jobs:
publish:
# Never publish from a fork that inherited this workflow.
if: github.repository == 'hasna/knowledge'
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:
- 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"
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"

# --minimum-release-age is deliberately NOT passed here. A frozen lockfile
# performs no resolution, so the age filter has nothing left to filter and
# the flag is a measured no-op: `bun install --frozen-lockfile
# --minimum-release-age 999999999` exits 0, while the same command without
# --frozen-lockfile exits 1 with "blocked by minimum-release-age". The
# quarantine is real, but it belongs at lockfile-UPDATE time; naming it
# here promised a protection this step cannot deliver.
- name: Install locked dependencies
run: bun install --frozen-lockfile

- name: Bind the tag to the package version
id: version
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"
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "manual run: ${pkg_name}@${pkg_version} from ${GITHUB_REF_NAME}"
exit 0
fi
case "${GITHUB_REF_NAME}" in
npm/knowledge/v*)
tag_version="${GITHUB_REF_NAME#npm/knowledge/v}" ;;
*)
echo "::error::tag ${GITHUB_REF_NAME} does not match a recognised release tag prefix"
exit 1 ;;
esac
if [ "${tag_version}" != "${pkg_version}" ]; then
echo "::error::tag ${GITHUB_REF_NAME} carries version ${tag_version} but package.json declares ${pkg_version}"
exit 1
fi
echo "tag ${GITHUB_REF_NAME} agrees with package.json ${pkg_version}"

# Tags are not protected by this repository's main-branch ruleset. Bind
# the release to reviewed history in the workflow itself so a tag on an
# unmerged branch cannot publish different code.
- name: Require the release commit on protected main
if: github.event_name == 'push'
run: |
set -euo pipefail
if ! git merge-base --is-ancestor "${GITHUB_SHA}" "refs/remotes/origin/main"; then
echo "::error::release commit ${GITHUB_SHA} is not contained in protected main"
exit 1
fi
echo "release commit ${GITHUB_SHA} is contained in protected main"

# 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
if: github.event_name == 'push'
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"

# No typecheck step: this repository declares no `typecheck` script, and a
# gate that cannot pass is worse than a missing one. Adding the script is
# separate work with its own review.

- name: Test
run: bun 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
if: github.event_name == 'push'
run: npm publish --provenance --access public

- name: Verify the published version from the registry
if: github.event_name == 'push'
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
Loading