Release
Merging a new version in package.json releases it. The workflow checks the package, stages
it on npm, then tags the merged commit vX.Y.Z and publishes a GitHub Release with the tarball.
Nobody pushes a tag by hand. This file is the same in every package, copied from ship's
package/ folder, so it names no package and reads everything from package.json.
npm uses trusted publishing, with OIDC and provenance, so there's no token to store. The publisher may only stage. The new version waits on npmjs.com until a maintainer approves it with 2FA, so a merge alone can't put a version in front of users.
name: release
on:
push:
branches: [main]
paths: [package.json]
# Runs it again on `main`, after a failure: `gh workflow run release`.
workflow_dispatch:
permissions:
contents: write
id-token: write
pull-requests: read
concurrency:
group: release
cancel-in-progress: false
jobs:
# A change to `package.json` that leaves `version` alone, like a dependency bump, finds its tag
# already there and stops here. So does a package that isn't on npm yet, whose first version is
# published by hand, since npm trusts this workflow only once the package exists.
version:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v7
- id: tag
env:
GH_TOKEN: ${{ github.token }}
run: |
name="$(node -p 'require("./package.json").name')"
tag="v$(node -p 'require("./package.json").version')"
if ! curl -sf "https://registry.npmjs.org/$name" >/dev/null; then
echo "$name isn't on npm yet: publish its first version by hand"
elif gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" >/dev/null 2>&1; then
echo "$tag is already tagged"
else
echo "tag=$tag" >> "$GITHUB_OUTPUT"
fi
release:
needs: version
if: needs.version.outputs.tag != ''
runs-on: ubuntu-latest
env:
TAG: ${{ needs.version.outputs.tag }}
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: package.json
cache: pnpm
- run: pnpm install --frozen-lockfile
# The same script CI and Cloudflare run. A package's own checks, like a size budget, go in
# `verify`, and publint runs inside `vp pack`.
- run: pnpm run verify
# `pnpm pack` runs `prepack` first, so the tarball never ships a stale `dist/`.
- run: pnpm pack
# A version with a pre-release part (`-rc.0`) goes to the `next` dist-tag, so it never
# becomes `latest`. A version already on npm is skipped, so a failed run can be rerun.
- name: Stage on npm
run: |
name="$(node -p 'require("./package.json").name')"
v="${TAG#v}"
if curl -sf "https://registry.npmjs.org/$name/$v" >/dev/null; then
echo "$name@$v is already on npm"
else
tag=latest; case "$v" in *-*) tag=next;; esac
pnpm stage publish ./*.tgz --no-git-checks --provenance --access public --tag "$tag"
fi
# Creating the release tags the commit. The notes are generated from the pull requests'
# labels (`.github/release.yml`). The description of the pull request that bumped the
# version, down to the first `---` line, goes above them, like what to change in a breaking
# release.
- name: Tag and publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
body="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '.[0].body // ""')"
notes="$(printf '%s\n' "$body" | sed '/^---[[:space:]]*$/,$d')"
pre=""; case "$TAG" in *-*) pre=--prerelease;; esac
gh release create "$TAG" ./*.tgz --title "$TAG" --target "$GITHUB_SHA" \
$pre --generate-notes --notes "$notes"