Skip to content

Release - prepare

Release - prepare #123

Workflow file for this run

name: Release - prepare
# Releases go through a pull request, the same gate as every other change.
# main requires a PR and GitHub Actions cannot be granted a bypass, so this
# workflow does not push to main: it opens a release PR instead. Publishing
# happens in "Release - publish" once that PR has landed.
#
# Defaults to a dry run: previewing costs nothing, publishing cannot be undone.
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Print the versions and changelogs as a diff, without opening a PR'
type: boolean
default: true
overrides:
description: 'Optional explicit versions, e.g. "@qkix/better-blocks-react-renderer=0.17.0 @qkix/better-blocks-astro-renderer=0.13.0". Applied after the conventional-commits pass, so they win.'
type: string
required: false
default: ''
projects:
description: 'Optional comma-separated projects to release, e.g. "@qkix/chartkit-core". Everything else keeps its current version. Leave empty to release whatever changed.'
type: string
required: false
default: ''
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
# nx release reads conventional commits since the last per-package tag.
fetch-depth: 0
- uses: pnpm/action-setup@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Versions first, changelogs second - and never interleaved.
#
# The combined `nx release` command does version+changelog in one pass, so
# applying an override meant running it twice, and BOTH passes wrote a
# changelog entry: one for the conventional-commits version, one for the
# override that supersedes it. The first is a version that never ships,
# and it stayed in the changelog forever. Releasing 0.2.0 of the Vue
# renderer left a phantom `## 0.1.1` above it.
#
# The subcommands keep the two apart: every version is settled on disk
# before a single changelog pass describes what is actually being
# released. This is why nx.json configures git under `release.version` and
# `release.changelog` rather than `release.git` - the top-level property
# is rejected by the subcommands, and setting it granularly in turn
# disables the combined command.
#
# Note on 0.x versions: nx maps a "minor" bump to a patch increment and a
# "major" bump to the middle number, so asking for "minor" on 0.12.0 gets
# you 0.12.1. Pass an exact version in `overrides` when that matters - a
# behavior change delivered as a patch reaches people running a routine
# update.
#
# `projects` exists because nx attributes a change to a file outside every
# package - the root README, a shared config, anything under examples/ -
# to *all* of them, which then cascades to their dependents as "a
# dependency was bumped". A one-line README edit alongside work in one
# package therefore proposes a release of the entire workspace, with
# changelogs that say nothing. Naming the projects keeps such a release
# honest.
#
# Nothing here is run with --dry-run. nx only edits files in this
# throwaway checkout, and a preview built from the real thing beats one
# built from a flag: a dry-run version pass writes no manifests, so the
# changelog pass that follows would have nothing to read. `dry_run`
# instead gates the commit, the branch and the PR below, and prints the
# diff it would have opened.
- name: Version and changelog
env:
OVERRIDES: ${{ inputs.overrides }}
PROJECTS: ${{ inputs.projects }}
run: |
set -euo pipefail
FILTER=""
if [ -n "$PROJECTS" ]; then
FILTER="--projects=$PROJECTS"
echo "Releasing only: $PROJECTS"
fi
pnpm exec nx release version $FILTER
for pair in $OVERRIDES; do
project="${pair%%=*}"
version="${pair#*=}"
if [ "$project" = "$pair" ] || [ -z "$version" ]; then
echo "Malformed override '$pair'; expected project=version." >&2
exit 1
fi
echo "Overriding $project to $version"
pnpm exec nx release version "$version" --projects="$project"
done
# The manifests now hold the versions that will actually ship, so one
# changelog pass per project describes exactly what is being released.
# It has to be per project: `nx release changelog` takes a single
# version, and these projects are versioned independently.
#
# A version that already has a tag has already shipped and has its
# entry - skipping those is the same test the PR body and "Release -
# publish" apply, so all three agree on what this release contains.
for manifest in packages/*/package.json; do
name=$(node -p "require('./$manifest').name")
private=$(node -p "require('./$manifest').private === true")
[ "$private" = "true" ] && continue
version=$(node -p "require('./$manifest').version")
if git rev-parse -q --verify "refs/tags/$name@$version" >/dev/null; then
continue
fi
echo "Changelog: $name@$version"
pnpm exec nx release changelog "$version" --projects="$name"
done
# A dry run stops here, having done the real work and thrown it away.
- name: Show what a real run would open
if: ${{ inputs.dry_run }}
run: |
set -euo pipefail
git add -A
if git diff --cached --quiet; then
echo "nx release changed nothing - there is nothing to release."
exit 0
fi
echo "::group::Files a real run would change"
git diff --cached --stat
echo "::endgroup::"
echo "::group::Full diff"
git diff --cached
echo "::endgroup::"
- name: Open the release PR
if: ${{ !inputs.dry_run }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Stage everything first: a release can *create* files - a package
# getting its first changelog - and both `git diff` and `git commit -a`
# ignore untracked paths, so checking or committing before staging
# silently drops them.
git add -A
if git diff --cached --quiet; then
echo "nx release changed nothing - there is nothing to release."
exit 0
fi
branch="release/$(date -u +%Y%m%d-%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git commit -m "chore: release"
git push origin "$branch"
# List what is about to be released, so the PR is reviewable at a glance.
#
# Only the packages this release actually ships. Listing every manifest
# version instead - which this used to do - reads as "the whole
# workspace is going out" on a release scoped with `projects`, because
# the untouched packages print the version they already have. Anyone
# reading the PR later has no way to tell those apart from the ones
# being published.
#
# A version with no tag yet is one that has not shipped. That is the
# same test "Release - publish" applies when it decides what to tag, so
# this list and what actually reaches npm cannot disagree.
released=""
held=0
for manifest in packages/*/package.json; do
name=$(node -p "require('./$manifest').name")
private=$(node -p "require('./$manifest').private === true")
[ "$private" = "true" ] && continue
version=$(node -p "require('./$manifest').version")
if git rev-parse -q --verify "refs/tags/$name@$version" >/dev/null; then
held=$((held + 1))
continue
fi
released="${released}- \`$name\` → **$version**"$'\n'
done
if [ -z "$released" ]; then
released="_No version changed; this release is changelog-only._"
fi
gh pr create \
--base main \
--head "$branch" \
--title "chore: release" \
--body "$(printf '%s\n\n%s\n%s\n\n%s\n' \
'Version bumps and changelogs generated by \`nx release\`. Only the packages being published are listed.' \
"$released" \
"_${held} package(s) keep the version they already have and will not be republished._" \
'GitHub holds CI on bot-authored PRs: approve the pending workflow run to get the required checks. Merging then tags the commit and publishes to npm automatically.')"