diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..746cbd3 --- /dev/null +++ b/.github/workflows/release.yml @@ -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