Release - prepare #123
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.')" |