name: Release
on:
push:
tags:
- 'v*.*.*'
jobs:
publish-npm:
name: Publish to public npm
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: npm
- name: Update npm for trusted publishing
run: npm install --global npm@^11.5.1
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Verify version matches tag
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "Version mismatch: package.json=$PKG_VERSION tag=$TAG_VERSION"
exit 1
fi
- name: Check if version already published
run: |
PKG_NAME=$(node -p "require('./package.json').name")
PKG_VERSION=$(node -p "require('./package.json').version")
if npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null; then
echo "ERROR: ${PKG_NAME}@${PKG_VERSION} is already published."
exit 1
fi
- name: Build
run: npm run build
- name: Resolve dist-tag
id: disttag
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
case "$PKG_VERSION" in
*-*)
# Prerelease (e.g. 0.21.0-beta.0). Derive the dist-tag from the
# first prerelease identifier (beta, rc, alpha, ...) so it lands
# on its own channel and NEVER on `latest` — the tag consumed by
# the CLI's auto-update check (src/update.ts).
PRE="${PKG_VERSION#*-}"
DIST_TAG="${PRE%%.*}"
# npm rejects dist-tags that look like a semver version, so fall
# back to `beta` when the identifier isn't a plain word.
case "$DIST_TAG" in
''|*[!a-zA-Z]*) DIST_TAG="beta" ;;
esac
IS_PRERELEASE=true
;;
*)
DIST_TAG="latest"
IS_PRERELEASE=false
;;
esac
echo "Publishing $PKG_VERSION with dist-tag: $DIST_TAG (prerelease=$IS_PRERELEASE)"
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
- name: Publish
run: npm publish --access public --tag "${{ steps.disttag.outputs.dist_tag }}"
- name: Resolve release notes base
id: notesbase
env:
IS_PRERELEASE: ${{ steps.disttag.outputs.is_prerelease }}
run: |
# GitHub picks the base as the newest tag reachable from the tagged
# commit. Release commits are created detached and never land on a
# branch, so no release tag is an ancestor of the next one and the
# base walks back to whichever old release happened to be merged.
# Pick it explicitly instead.
#
# A prerelease compares against the tag right before it, keeping its
# notes incremental; a stable release compares against the previous
# stable tag. Either way, skip tags whose own parent is not in this
# release's history: those were cut from an unrelated lineage (an
# internal mirror sync), so their merge-base sits months back and
# would drag hundreds of unrelated PRs into the notes.
git config --add versionsort.suffix -beta
git config --add versionsort.suffix -rc
if [ "$IS_PRERELEASE" = "true" ]; then
CANDIDATES=$(git tag -l 'v*' --sort=-v:refname)
else
CANDIDATES=$(git tag -l 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$')
fi
PREV=""
PAST_CURRENT=""
for TAG in $CANDIDATES; do
if [ "$TAG" = "$GITHUB_REF_NAME" ]; then
PAST_CURRENT=1
continue
fi
[ -n "$PAST_CURRENT" ] || continue
if git merge-base --is-ancestor "$TAG^{commit}^1" "$GITHUB_REF_NAME^{commit}" 2>/dev/null; then
PREV="$TAG"
break
fi
echo "Skipping $TAG: cut from an unrelated lineage."
done
if [ -n "$PREV" ]; then
echo "Release notes base: $PREV"
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
else
echo "No usable earlier tag; letting GitHub pick the base."
fi
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREV_TAG: ${{ steps.notesbase.outputs.prev_tag }}
run: |
ARGS=(-f tag_name="$GITHUB_REF_NAME")
if [ -n "$PREV_TAG" ]; then
ARGS+=(-f previous_tag_name="$PREV_TAG")
fi
gh api --method POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
"${ARGS[@]}" -q .body > "${RUNNER_TEMP}/release-notes.md"
cat "${RUNNER_TEMP}/release-notes.md"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: ${{ runner.temp }}/release-notes.md
prerelease: ${{ steps.disttag.outputs.is_prerelease == 'true' }}