Skip to content
Merged
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
171 changes: 171 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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/conversations. 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/conversations 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/conversations/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-conversations-npm-release
cancel-in-progress: false

permissions:
contents: read

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

- 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
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/conversations/v*)
tag_version="${GITHUB_REF_NAME#npm/conversations/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"

# `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
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