Skip to content

Release

Release #193

Workflow file for this run

name: Release
on:
push:
tags: ['v*.*.*', 'v*.*.*-*']
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish, for example v17.0.0'
required: true
type: string
permissions:
contents: read
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
verify:
name: Verify metadata and publishability
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
tag_version: ${{ steps.meta.outputs.tag_version }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
npm_dist_tag: ${{ steps.meta.outputs.npm_dist_tag }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- name: Install
run: npm ci
- name: Validate tag format and compute metadata
id: meta
shell: bash
run: |
set -euo pipefail
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# Validate tag format (same regex as tag-guard.yml)
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then
echo "Invalid tag format: $TAG"
exit 1
fi
TAG_VERSION="${TAG#v}"
echo "tag_version=$TAG_VERSION" >> "$GITHUB_OUTPUT"
if [[ "$TAG_VERSION" =~ -(rc|beta|alpha)\. ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
if [[ "$TAG_VERSION" =~ -rc\. ]]; then
echo "npm_dist_tag=next" >> "$GITHUB_OUTPUT"
elif [[ "$TAG_VERSION" =~ -beta\. ]]; then
echo "npm_dist_tag=beta" >> "$GITHUB_OUTPUT"
else
echo "npm_dist_tag=alpha" >> "$GITHUB_OUTPUT"
fi
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "npm_dist_tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Verify tag == package.json version
shell: bash
run: |
set -euo pipefail
PKG_VERSION=$(node -p "require('./package.json').version")
TAG_VERSION="${{ steps.meta.outputs.tag_version }}"
echo "package.json: $PKG_VERSION"
echo "tag: $TAG_VERSION"
test "$PKG_VERSION" = "$TAG_VERSION"
- name: Verify jsr version == tag
shell: bash
run: |
set -euo pipefail
TAG_VERSION="${{ steps.meta.outputs.tag_version }}"
if [ -f jsr.json ]; then
JSR_VERSION=$(node -p "require('./jsr.json').version")
elif [ -f jsr.jsonc ]; then
JSR_VERSION=$(grep -Eo '"version"\s*:\s*"[^"]+"' jsr.jsonc | head -n1 | sed -E 's/.*"([^"]+)"/\1/')
else
echo "Missing jsr.json/jsr.jsonc"
exit 1
fi
echo "jsr: $JSR_VERSION"
echo "tag: $TAG_VERSION"
test "$JSR_VERSION" = "$TAG_VERSION"
- name: Verify CHANGELOG has dated entry for this version
shell: bash
run: |
set -euo pipefail
TAG_VERSION="${{ steps.meta.outputs.tag_version }}"
if grep -qE "^\#*\s*\[?${TAG_VERSION}\]?\s*[—–-]\s*[0-9]{4}-[0-9]{2}-[0-9]{2}" CHANGELOG.md; then
echo "CHANGELOG entry found for $TAG_VERSION"
else
echo "ERROR: No dated CHANGELOG entry for $TAG_VERSION"
exit 1
fi
- name: Verify published files
run: npm pack --dry-run
- name: Verify JSR publishability
run: npx -y jsr publish --dry-run
publish_npm:
name: Publish npm (OIDC trusted publishing)
runs-on: ubuntu-latest
needs: verify
permissions:
contents: read
id-token: write
environment: npm
continue-on-error: true
outputs:
package_name: ${{ steps.pkg.outputs.name }}
version: ${{ steps.pkg.outputs.version }}
status: ${{ steps.npm_result.outputs.status }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.verify.outputs.tag }}
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install
run: npm ci
- name: Read package metadata
id: pkg
shell: bash
run: |
echo "name=$(node -p "require('./package.json').name")" >> "$GITHUB_OUTPUT"
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Skip if npm version already exists
id: npm_exists
shell: bash
run: |
set -euo pipefail
PACKAGE="${{ steps.pkg.outputs.name }}"
VERSION="${{ steps.pkg.outputs.version }}"
if npm view "${PACKAGE}@${VERSION}" version >/dev/null 2>&1; then
echo "::warning::npm version ${PACKAGE}@${VERSION} already exists; skipping immutable publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish npm (retry x3)
id: npm_publish
if: ${{ steps.npm_exists.outputs.skip != 'true' }}
uses: ./.github/actions/retry
with:
attempts: '3'
delay_seconds: '15'
command: npm publish --access public --tag "${{ needs.verify.outputs.npm_dist_tag }}" --provenance
- name: Report npm publish status
id: npm_result
if: ${{ always() }}
env:
NPM_EXISTS: ${{ steps.npm_exists.outputs.skip }}
NPM_PUBLISH_OUTCOME: ${{ steps.npm_publish.outcome }}
shell: bash
run: |
set -euo pipefail
if [[ "$NPM_EXISTS" == "true" ]]; then
echo "status=already-exists" >> "$GITHUB_OUTPUT"
elif [[ "$NPM_PUBLISH_OUTCOME" == "success" ]]; then
echo "status=published" >> "$GITHUB_OUTPUT"
else
echo "status=failed" >> "$GITHUB_OUTPUT"
fi
publish_jsr:
name: Publish JSR
runs-on: ubuntu-latest
needs: verify
permissions:
contents: read
id-token: write
environment: jsr
continue-on-error: true
outputs:
status: ${{ steps.jsr_result.outputs.status }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.verify.outputs.tag }}
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- name: Install
run: npm ci
- name: Read JSR metadata
id: jsr_pkg
shell: bash
run: |
set -euo pipefail
NAME=$(node -p "require('./jsr.json').name")
VERSION=$(node -p "require('./jsr.json').version")
NPM_NAME=$(node - <<'NODE'
const name = require('./jsr.json').name;
if (!name.startsWith('@')) {
throw new Error(`Unsupported unscoped JSR package name: ${name}`);
}
const [scope, pkg] = name.slice(1).split('/');
if (!scope || !pkg) {
throw new Error(`Unsupported JSR package name: ${name}`);
}
process.stdout.write(`@jsr/${scope}__${pkg}`);
NODE
)
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "npm_name=$NPM_NAME" >> "$GITHUB_OUTPUT"
- name: Skip if JSR version already exists
id: jsr_exists
shell: bash
run: |
set -euo pipefail
PACKAGE="${{ steps.jsr_pkg.outputs.npm_name }}"
VERSION="${{ steps.jsr_pkg.outputs.version }}"
if npm view "${PACKAGE}@${VERSION}" version --registry=https://npm.jsr.io >/dev/null 2>&1; then
echo "::warning::JSR version ${{ steps.jsr_pkg.outputs.name }}@${VERSION} already exists; skipping immutable publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish JSR (retry x3)
id: jsr_publish
if: ${{ steps.jsr_exists.outputs.skip != 'true' }}
uses: ./.github/actions/retry
with:
attempts: '3'
delay_seconds: '15'
command: npx -y jsr publish
- name: Report JSR publish status
id: jsr_result
if: ${{ always() }}
env:
JSR_EXISTS: ${{ steps.jsr_exists.outputs.skip }}
JSR_PUBLISH_OUTCOME: ${{ steps.jsr_publish.outcome }}
shell: bash
run: |
set -euo pipefail
if [[ "$JSR_EXISTS" == "true" ]]; then
echo "status=already-exists" >> "$GITHUB_OUTPUT"
elif [[ "$JSR_PUBLISH_OUTCOME" == "success" ]]; then
echo "status=published" >> "$GITHUB_OUTPUT"
else
echo "status=failed" >> "$GITHUB_OUTPUT"
fi
github_release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [verify, publish_npm, publish_jsr]
if: ${{ always() && needs.verify.result == 'success' }}
permissions:
contents: write
steps:
- name: Build registry summary
id: summary
shell: bash
run: |
cat > RELEASE_SUMMARY.md << 'EOF'
## Registry publish summary
- npm: `${{ needs.publish_npm.outputs.status }}`
- JSR: `${{ needs.publish_jsr.outputs.status }}`
Dist-tag: `${{ needs.verify.outputs.npm_dist_tag }}`
Version: `${{ needs.verify.outputs.tag_version }}`
If one registry failed, re-run only that job from Actions.
EOF
- name: Create / update release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.verify.outputs.tag }}
IS_PRERELEASE: ${{ needs.verify.outputs.is_prerelease }}
shell: bash
run: |
set -euo pipefail
if gh release view "$TAG" --repo "$GH_REPO" >/dev/null 2>&1; then
gh release view "$TAG" --repo "$GH_REPO" --json body --jq .body > EXISTING_RELEASE.md
if grep -q "^## What's Changed" EXISTING_RELEASE.md; then
awk 'BEGIN { keep = 0 } /^## What'\''s Changed/ { keep = 1 } keep { print }' EXISTING_RELEASE.md > GENERATED_NOTES.md
cat RELEASE_SUMMARY.md > RELEASE_NOTES.md
printf '\n' >> RELEASE_NOTES.md
cat GENERATED_NOTES.md >> RELEASE_NOTES.md
gh release edit "$TAG" --repo "$GH_REPO" --notes-file RELEASE_NOTES.md
else
echo "::warning::GitHub Release $TAG already exists but generated notes were not recognized; leaving notes unchanged."
fi
else
SUMMARY=$(cat RELEASE_SUMMARY.md)
args=(release create "$TAG" --repo "$GH_REPO" --generate-notes --notes "$SUMMARY")
if [[ "$IS_PRERELEASE" == "true" ]]; then
args+=(--prerelease)
fi
gh "${args[@]}"
fi
- name: Fail if both registries failed
if: ${{ needs.publish_npm.outputs.status == 'failed' && needs.publish_jsr.outputs.status == 'failed' }}
run: |
echo "Both npm and JSR publish failed."
exit 1