From 85758d223ecff8a132b0ca4822fcbd74c3801535 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Mon, 9 Nov 2026 15:16:14 +0000 Subject: [PATCH 1/2] ci: cut a stable release with one workflow run and one PR A stable release used to take a hand-built promotion branch (dev, a merge of production, and an empty Release-As pin commit), a promotion PR, then release-please's own Release PR, then the back-merge PR. A new "Promote to stable" workflow (promote.yml) now builds the release branch: dev, with production merged in, plus one commit that sets the version in package.json and server.json and adds the CHANGELOG.md section. It opens that as the only PR into production. Merging it is the release: the Release workflow tags the untagged version, creates the GitHub Release from the changelog section, and publishes npm, the binaries and the back-merge as before. scripts/stable-release.mjs derives the version from the Conventional Commit PR titles since the last release, as release-please did, but never derives a major: a breaking marker stops the run until the version is given explicitly. Its changelog lists each PR once, using the full title when GitHub cut it short, where release-please listed such PRs twice. Production is merged in on every run because dev only allows squash merges, so the back-merge can never make production an ancestor of dev. release-please, its config and manifest, and the Release-As promotion-pin check are removed. A release-check job verifies every PR into production instead. Release PRs skip claude-review, like back-merge PRs, because the review action rejects bot actors. The Release workflow keeps the release-please.yml filename, because npm's trusted publisher is registered against it. --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/dependabot.yml | 2 +- .github/workflows/cla.yml | 10 +- .github/workflows/claude-code-review.yml | 14 +- .github/workflows/cli-ci.yml | 77 +--- .github/workflows/npm-publish.yml | 16 +- .github/workflows/pr-title-lint.yml | 8 +- .github/workflows/promote.yml | 189 +++++++++ .github/workflows/release-beta.yml | 6 +- .github/workflows/release-binaries.yml | 9 +- .github/workflows/release-please.yml | 133 ++++--- .release-please-manifest.json | 3 - CLAUDE.md | 38 +- CONTRIBUTING.md | 17 +- release-please-config.json | 34 -- scripts/stable-release.mjs | 467 +++++++++++++++++++++++ test/unit/server-json.test.ts | 28 +- test/unit/stable-release.test.ts | 297 ++++++++++++++ 18 files changed, 1113 insertions(+), 239 deletions(-) create mode 100644 .github/workflows/promote.yml delete mode 100644 .release-please-manifest.json delete mode 100644 release-please-config.json create mode 100644 scripts/stable-release.mjs create mode 100644 test/unit/stable-release.test.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 288cc94..1c22b44 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ @@ -27,7 +27,7 @@ - [ ] `pnpm lint` passes - [ ] `pnpm typecheck` passes - [ ] `pnpm build` passes -- [ ] I have **not** bumped the version or edited `CHANGELOG.md` (release-please handles this) +- [ ] I have **not** bumped the version or edited `CHANGELOG.md` (the release workflow handles this) - [ ] I have signed the CLA (the bot will prompt on first contribution) - [ ] Docs / `README.md` / `STYLE_GUIDE.md` updated if behaviour or output changed diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3ffe6e3..13d6a53 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,7 +16,7 @@ updates: open-pull-requests-limit: 10 commit-message: # Conventional Commit prefix so the squashed PR title matches our PR-title - # lint and release-please picks dependency bumps into the changelog. + # lint and the release changelog lists dependency bumps. prefix: deps prefix-development: chore groups: diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 24fe627..cf0be43 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -21,13 +21,13 @@ name: CLA Assistant # the org access policy set. Do not flip it private. # # AUTH: mints a token from the shared automation GitHub App (the same App -# release-please uses), so signature commits show as the bot and there's no +# the release workflows use), so signature commits show as the bot and there's no # personal token to expire. # # SETUP REQUIRED before this enforces anything: # 1. Create/install the automation GitHub App (Contents R/W, Pull requests R/W, # Issues R/W) and add BOT_APP_ID + BOT_APP_PRIVATE_KEY repo secrets — the -# same secrets release-please uses. +# same secrets the release workflows use. # 2. Create the `cla-signatures` branch (empty orphan) so the action has # somewhere to write `signatures/version1/cla.json`. # 3. Finalise CLA.md (legal review) — it's the document contributors agree to. @@ -73,9 +73,9 @@ jobs: path-to-document: "https://github.com/devicecloud-dev/dcd-cli/blob/dev/CLA.md" branch: "cla-signatures" # Do NOT lock the PR on merge (the action's default is true). release-please - # comments on its release PR *after* merge; a locked conversation makes that - # comment fail and takes down the whole Release job (npm publish + binaries - # never run). Keeping this false is load-bearing for the release pipeline. + # used to comment on its release PR *after* merge, and a locked conversation + # failed that comment and the whole Release job with it. Nothing comments + # after merge now, but locking gains nothing, so it stays off. lock-pullrequest-aftermerge: false # Internal maintainers (covered by employment/CCLA) + bots skip the prompt. allowlist: riglar,finalerock44,dependabot[bot],renovate[bot],*[bot] diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8e8a6dc..77e3e97 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,13 +14,13 @@ jobs: claude-review: # Skip PRs we shouldn't (or can't) review: # - Dependabot / forks: no CLAUDE_CODE_OAUTH_TOKEN, so the action would fail. - # - release-please release PRs: just version bumps + changelog — nothing to - # review, and it must never block a release. - # - Back-merge PRs (production -> dev, opened by release-please.yml): only - # carry code already reviewed on its way through dev, and the action - # rejects their bot actor ("Workflow initiated by non-human actor"), - # which blocks the merge since claude-review is a required check. - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && !startsWith(github.head_ref, 'release-please--') && !startsWith(github.head_ref, 'chore/back-merge-') }} + # - Release PRs (dev -> production, opened by promote.yml) and back-merge + # PRs (production -> dev, opened by release-please.yml): they only carry + # code already reviewed on its way through dev, plus the version and + # changelog commit, and the action rejects their bot actor ("Workflow + # initiated by non-human actor"), which blocks the merge since + # claude-review is a required check. + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && !(github.base_ref == 'production' && startsWith(github.head_ref, 'promote/cli-')) && !startsWith(github.head_ref, 'chore/back-merge-') }} runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index fddf327..cdd1a83 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -102,71 +102,24 @@ jobs: working-directory: ./cli run: pnpm audit --audit-level moderate - # Promotions to `production` MUST pin the stable version with a `Release-As:` - # footer on a commit. Without it release-please derives the stable version - # from the most recent tag reachable from `production` — and because a - # promotion is a merge commit, every beta tag is reachable, so it picks up a - # `-beta` version. That is exactly how 5.6.0 ended up with `production` - # carrying 5.6.0-beta.1 in package.json and a release PR that could not - # publish. 5.5.0 got a pin (`chore: pin the 5.5.0 promotion`) and came out - # correct; 5.6.0's was abandoned and did not. - # - # It has to be on a NORMAL commit, not the merge commit — release-please's - # commit splitting is unreliable on merges. - # - # Release-please's own release PR also targets `production` but is not a - # promotion: it never carries a pin, so the check would always fail on it. - promotion-pin: - if: github.event_name == 'pull_request' && github.base_ref == 'production' && !startsWith(github.head_ref, 'release-please--') + # Every PR into `production` is a release, built by promote.yml: `dev` with + # `production` merged in, plus a commit that sets a new version in + # package.json and server.json and adds its CHANGELOG.md section. This checks + # that commit is there and consistent, against the merge result, so a hand-made + # PR can't land something the Release workflow would skip (version already + # tagged) or refuse (version files out of step). + release-check: + if: github.event_name == 'pull_request' && github.base_ref == 'production' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: + # Tags, to check the version is new and above every stable release. fetch-depth: 0 - - name: Require a stable Release-As pin - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - git fetch --quiet origin dev - - # Scope matters more than the pattern here. A promotion merges the whole - # of `dev`, whose history carries a `Release-As:` footer from every past - # promotion (5.0.0, 5.1.0, 5.1.1, 5.2.0, 5.3.0, 5.3.1 …). Scanning - # `base..head` therefore always finds one and passes vacuously — checked - # against the real #176, which it waved through. Only commits unique to - # this promotion branch count: everything on `production` and everything - # on `dev` is excluded. `--no-merges` because release-please's commit - # splitting is unreliable on merge commits, so a pin has to sit on an - # ordinary one. - # - # NB `^ref` not `--not ref`: --not is a TOGGLE over everything that - # follows, so `--not A --not B` excludes A and re-includes B. - MESSAGES=$(git log --no-merges --format=%B "$HEAD_SHA" "^$BASE_SHA" "^origin/dev") - - if echo "$MESSAGES" | grep -qE '^Release-As:[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*$'; then - echo "Found $(echo "$MESSAGES" | grep -oE '^Release-As:[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+' | head -1)" - exit 0 - fi - - if echo "$MESSAGES" | grep -qE '^Release-As:'; then - echo "::error::This promotion pins a PRERELEASE version. The stable line must be pinned to a plain X.Y.Z." - echo "$MESSAGES" | grep -E '^Release-As:' >&2 - exit 1 - fi - - echo "::error::No 'Release-As: X.Y.Z' footer on any non-merge commit unique to this promotion." - { - echo "Add one as its own commit on the promotion branch:" - echo " git commit --allow-empty -m 'chore: pin the X.Y.Z promotion' -m 'Release-As: X.Y.Z'" - echo - echo "Do not skip it on the grounds that the conventional commits since the last" - echo "stable already imply the right bump. They do not: a promotion is a merge, so" - echo "every beta tag becomes reachable from production, and release-please picks the" - echo "newest reachable tag as its base. That is how the 5.6.0 promotion — which" - echo "reasoned exactly that way — produced a 'chore(production): release 5.6.0-beta.1'" - echo "release PR and left production carrying a prerelease in package.json." - } >&2 - exit 1 + - uses: actions/setup-node@v7 + with: + node-version: '24.x' + + - name: Check this PR is a well-formed release + run: node scripts/stable-release.mjs verify diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b2a74b7..7c92770 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,7 +1,8 @@ name: Publish Package to npmjs # Publishes the STABLE package to npm's `latest` tag. Called by -# release-please.yml after a stable Release PR merges on `production`. +# release-please.yml (the Release workflow) after a release PR merges on +# `production`. # # Betas do NOT come through here — .github/workflows/release-beta.yml publishes # those directly. This file used to take a `release_type` of prod or beta, but @@ -60,10 +61,10 @@ jobs: - run: pnpm install --frozen-lockfile # Stable publishes land on the npm `latest` tag and must only ever come - # from the `production` branch (release-please's prod target branch). - # Without this guard, workflow_dispatch could publish any ref as `latest`, - # bypassing release-please. workflow_call is unaffected: release-please.yml - # only runs on pushes to `production`, so github.ref_name matches there. + # from the `production` branch. Without this guard, workflow_dispatch + # could publish any ref as `latest`, bypassing the release PR. + # workflow_call is unaffected: release-please.yml only runs on + # `production`, so github.ref_name matches there. - name: Enforce production branch if: ${{ github.ref_name != 'production' }} run: | @@ -72,8 +73,9 @@ jobs: # Catches the failure mode that stalled 5.6.0: a promotion merge dragged # `dev`'s beta version onto `production`, so package.json read - # 5.6.0-beta.1 while the stable manifest still said 5.5.0. Publishing that - # to `latest` would have shipped a prerelease to every default install. + # 5.6.0-beta.1. Publishing that to `latest` would have shipped a + # prerelease to every default install. stable-release.mjs verify refuses + # such a version before the tag is made; this is the last backstop. - name: Validate Production Version run: | VERSION=$(node -p "require('./package.json').version") diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml index e3f8cb8..d102449 100644 --- a/.github/workflows/pr-title-lint.yml +++ b/.github/workflows/pr-title-lint.yml @@ -1,9 +1,9 @@ name: PR Title # Enforces Conventional Commits on the PR *title*. Because PRs are squash-merged -# with the title as the commit subject, this is what release-please parses to -# compute version bumps and the changelog — so the allowed types below must stay -# in sync with `changelog-sections` in release-please-config.json. +# with the title as the commit subject, this is what scripts/stable-release.mjs +# parses to compute version bumps and the changelog, so the allowed types below +# must stay in sync with SECTIONS and HIDDEN there. # # Uses pull_request_target so it also runs (and reports a required status check) # on PRs from forks. It only reads the title — no untrusted code is checked out. @@ -27,7 +27,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - # Keep in lockstep with release-please-config.json changelog-sections. + # Keep in lockstep with SECTIONS and HIDDEN in scripts/stable-release.mjs. types: | feat fix diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..dba822d --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,189 @@ +name: Promote to stable + +# Turns `dev` into a stable release PR in one step. Run it from the Actions tab +# or with `gh workflow run promote.yml --ref dev`. +# +# It builds `promote/cli-X.Y.Z`: `dev`, with `production` merged in, plus one +# commit that sets the version in package.json and server.json and adds the +# CHANGELOG.md section. Then it opens that branch as a PR into `production`. +# Merging the PR (with a merge commit, the only method `production` allows) is +# the release: release-please.yml tags it, creates the GitHub Release, publishes +# npm `latest` and the binaries, and opens the back-merge PR into `dev`. +# +# The version comes from the Conventional Commit PR titles since the last +# stable release (feat -> minor, fix/perf/deps/revert/refactor -> patch). A +# major is never derived: a breaking marker makes the run fail until the +# version is given explicitly. See scripts/stable-release.mjs. +# +# `production` is merged in because `dev` only allows squash merges, so the +# back-merge after a release can never make `production` an ancestor of `dev`. +on: + workflow_dispatch: + inputs: + version: + description: 'Stable version, e.g. 5.13.0. Leave blank to derive it from the PR titles since the last release.' + required: false + type: string + dry_run: + description: 'Work out the version and changelog and build the branch, but push nothing and open no PR.' + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: promote-to-stable + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + steps: + # The App token is required, not preferred: a PR opened with GITHUB_TOKEN + # does not trigger the checks `production` requires, so it could never merge. + - name: Check how this was started + run: | + if [ "$GITHUB_REF_NAME" != "dev" ]; then + echo "::error::Run this workflow from dev, not $GITHUB_REF_NAME." + exit 1 + fi + if [ -z "$BOT_APP_ID" ]; then + echo "::error::BOT_APP_ID is not set. The release PR has to be opened by the automation App so the required checks run on it." + exit 1 + fi + + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v7 + with: + ref: dev + # Full history and tags: the version is derived from the commits since + # the last release and checked against every existing tag. + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: actions/setup-node@v7 + with: + node-version: '24.x' + + # One release in flight at a time. A second PR would carry the first's + # commits again under another version. + - name: Check no release PR is open + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + open=$(gh pr list --base production --state open --json number,headRefName --jq '.[] | "#\(.number) (\(.headRefName))"' | paste -sd, -) + if [ -n "$open" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "::warning::A PR into production is already open: ${open}. A real run would stop here." + else + echo "::error::A PR into production is already open: ${open}. Merge or close it first." + exit 1 + fi + fi + + - name: Work out the version and changelog + id: plan + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + args=(--base origin/production --head origin/dev --notes-out "$RUNNER_TEMP/notes.md") + if [ -n "$INPUT_VERSION" ]; then args+=(--version "$INPUT_VERSION"); fi + node scripts/stable-release.mjs plan "${args[@]}" + + - name: Build the release branch + id: branch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + VERSION: ${{ steps.plan.outputs.version }} + run: | + set -euo pipefail + # Commit as the App's bot user, so the commits are attributed to it and + # the CLA check, which allowlists bots, passes. + user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + + branch="promote/cli-${VERSION}" + git checkout -B "$branch" origin/dev + + if git merge-base --is-ancestor origin/production HEAD; then + echo "dev already contains production." + elif ! git merge --no-ff -m "chore: merge production into the ${VERSION} release" origin/production; then + conflicts=$(git diff --name-only --diff-filter=U | paste -sd, -) + git merge --abort + echo "::error::Merging production into dev conflicts in ${conflicts}. Build this release by hand; see Releases in CLAUDE.md." + exit 1 + fi + + node scripts/stable-release.mjs apply --version "$VERSION" --notes "$RUNNER_TEMP/notes.md" + git add package.json server.json CHANGELOG.md + git commit -m "chore: release ${VERSION}" + + # The same check CI runs on the PR, so a bad branch fails here instead. + node scripts/stable-release.mjs verify + echo "name=${branch}" >> "$GITHUB_OUTPUT" + + - name: Write the PR body + env: + VERSION: ${{ steps.plan.outputs.version }} + PREVIOUS: ${{ steps.plan.outputs.previous }} + BUMP: ${{ steps.plan.outputs.bump }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ "$BUMP" = "requested" ]; then + how="version set by hand; previous release ${PREVIOUS}" + else + how="${BUMP} bump from ${PREVIOUS}, derived from the PR titles" + fi + dev_sha=$(git rev-parse --short origin/dev) + { + echo "Releases **${VERSION}** to npm \`latest\` (${how})." + echo + cat "$RUNNER_TEMP/notes.md" + echo + echo "---" + echo + echo "Opened by the [Promote to stable](${RUN_URL}) workflow: \`dev\` at ${dev_sha}, with \`production\` merged in, plus a commit that sets the version in package.json and server.json and adds the CHANGELOG.md section above." + echo + echo "- Merge with **Create a merge commit**, the only method \`production\` allows." + echo "- Merging is the release: the Release workflow tags \`v${VERSION}\`, creates the GitHub Release from the section above, publishes npm \`latest\` and the binaries, and opens the back-merge PR into \`dev\`." + echo "- To ship something different, close this PR, land the change on \`dev\`, and run the workflow again." + } > "$RUNNER_TEMP/body.md" + { + if [ "$DRY_RUN" = "true" ]; then echo "### ${VERSION} (dry run: nothing pushed)"; else echo "### ${VERSION}"; fi + echo + cat "$RUNNER_TEMP/body.md" + echo + echo '```' + git log --oneline --graph -n 4 + git diff --shortstat origin/production HEAD + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # The branch belongs to this workflow, so a leftover from a closed PR for + # the same version is overwritten. + - name: Push the branch and open the release PR + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.plan.outputs.version }} + BRANCH: ${{ steps.branch.outputs.name }} + run: | + set -euo pipefail + git push --force origin "HEAD:refs/heads/${BRANCH}" + url=$(gh pr create --base production --head "$BRANCH" --title "chore: release ${VERSION}" --body-file "$RUNNER_TEMP/body.md") + echo "Opened ${url}" + { echo; echo "Release PR: ${url}"; } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index cb5f7b1..a42a53f 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -9,8 +9,8 @@ name: Release beta # version is derived from the registry, so a beta is always above the current # stable by construction. See scripts/next-beta-version.mjs. # -# Stable releases are unaffected and stay automatic — release-please on -# `production`, driven by .github/workflows/release-please.yml. +# Stable releases are separate: .github/workflows/promote.yml opens the release +# PR into `production`, and .github/workflows/release-please.yml publishes it. on: workflow_dispatch: inputs: @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: # Betas come off `dev` or a feature branch. `production` is the stable line - # and release-please owns its package.json version there; cutting a beta + # and the release PR sets its package.json version there; cutting a beta # from it would build from a tree that another process is also writing. - name: Reject beta releases from production if: github.ref_name == 'production' diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index f8cbd7a..251ff77 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -1,9 +1,10 @@ name: Publish standalone binaries -# Invoked by release-please.yml after a GitHub Release is created, so a single -# release produces both the npm package and the standalone binaries. The infra -# Railway proxy (`get.devicecloud.dev`) reads from the GitHub Releases API at -# runtime, so no separate manifest deploy is needed. +# Invoked by release-please.yml (the Release workflow) after it creates the +# GitHub Release, so a single release produces both the npm package and the +# standalone binaries. The infra Railway proxy (`get.devicecloud.dev`) reads +# from the GitHub Releases API at runtime, so no separate manifest deploy is +# needed. on: workflow_call: inputs: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 48e012c..9404984 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,55 +1,81 @@ name: Release -# Drives the STABLE release pipeline. A push to `production` makes release-please -# open/update a Release PR; merging that PR creates the tag + GitHub Release whose -# body is the freshly-rendered changelog section, and the same workflow run chains -# into npm publish + binary uploads. (Chained as jobs because GITHUB_TOKEN-created -# releases don't trigger workflows listening on `release: published`.) +# Publishes a STABLE release once its release PR lands on `production`. # -# Betas are NOT handled here — they are cut by hand from -# .github/workflows/release-beta.yml. They used to be a second release-please -# track on `dev` with its own config and manifest, which numbered itself without -# reference to what stable had shipped and so kept publishing betas that sorted -# below `@latest`. See that workflow's header for the full story. +# The release PR is built by .github/workflows/promote.yml: `dev`, with +# `production` merged in, plus a commit that sets the version in package.json and +# server.json and adds the CHANGELOG.md section. So a push to `production` that +# carries an untagged version is a release: this tags it, creates the GitHub +# Release from that changelog section, and chains into npm publish, the binary +# uploads and the back-merge into `dev`. A push whose version is already tagged +# is not a release, and does nothing. +# +# The file keeps its old name because npm's trusted publisher is registered +# against `release-please.yml`: npm checks the filename of the workflow that +# started the run, so renaming this means updating that registration first. +# release-please itself is gone. It needed a promotion PR, a `Release-As:` pin +# on it (or it based the version on a beta tag), and then its own Release PR. +# +# Betas are NOT handled here: they are cut by hand from +# .github/workflows/release-beta.yml. on: push: branches: [production] + # Re-run lever: does the same as a push, so it releases `production`'s + # version if that is still untagged and does nothing otherwise. workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read jobs: - release-please-prod: + release: if: github.ref_name == 'production' runs-on: ubuntu-latest - # Empty until the automation GitHub App secrets are configured (the same App - # powers the CLA workflow). We use an App token (not GITHUB_TOKEN) so the - # Release PR triggers CI / PR-title / CLA - # checks — PRs opened by GITHUB_TOKEN do not, which would deadlock branch - # protection. Falls back to GITHUB_TOKEN (today's behaviour) until the App - # is set up, so this is safe to merge before then. - env: - BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + permissions: + contents: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} steps: - - uses: actions/create-github-app-token@v3 - id: app-token - if: env.BOT_APP_ID != '' + - uses: actions/checkout@v7 with: - app-id: ${{ secrets.BOT_APP_ID }} - private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - uses: googleapis/release-please-action@v5 - id: release + # Tags, for the "already released" and "above every stable tag" checks. + fetch-depth: 0 + + - uses: actions/setup-node@v7 with: - token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} - target-branch: production - config-file: release-please-config.json - manifest-file: .release-please-manifest.json + node-version: '24.x' + + # GITHUB_TOKEN, so the `release: published` event triggers no workflow; + # the publishing jobs below are chained with `needs:` instead. + - name: Tag and create the GitHub Release + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version=$(node -p "require('./package.json').version") + tag="v${version}" + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "${tag} is already tagged, so this push is not a release." + echo "release_created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + node scripts/stable-release.mjs verify + node scripts/stable-release.mjs notes --version "$version" > "$RUNNER_TEMP/notes.md" + gh release create "$tag" \ + --target "$GITHUB_SHA" \ + --title "$tag" \ + --notes-file "$RUNNER_TEMP/notes.md" \ + --latest + { + echo "release_created=true" + echo "tag_name=${tag}" + echo "version=${version}" + } >> "$GITHUB_OUTPUT" publish-npm-prod: # Must be granted here too: a reusable workflow can never hold a @@ -59,28 +85,32 @@ jobs: permissions: contents: read id-token: write - needs: release-please-prod - if: needs.release-please-prod.outputs.release_created == 'true' + needs: release + if: needs.release.outputs.release_created == 'true' uses: ./.github/workflows/npm-publish.yml secrets: inherit publish-binaries-prod: - needs: release-please-prod - if: needs.release-please-prod.outputs.release_created == 'true' + # Granted here for the same reason: release-binaries.yml uploads the + # release assets, and this workflow's top-level block is read-only. + permissions: + contents: write + needs: release + if: needs.release.outputs.release_created == 'true' uses: ./.github/workflows/release-binaries.yml with: - tag_name: ${{ needs.release-please-prod.outputs.tag_name }} + tag_name: ${{ needs.release.outputs.tag_name }} secrets: inherit - # Stable releases land on `production` and were never merged back, so `dev` - # accumulated a permanent one-way divergence: 26 commits at the last count, - # every one of them release bookkeeping, growing by ~3 per release forever. - # That also left `dev`'s package.json and CHANGELOG.md stale between releases. - # This opens the back-merge automatically so the next promotion's diff is only - # the commits since this release. + # Brings the release commit back to `dev`, so its package.json, server.json + # and CHANGELOG.md match what shipped (CHANGELOG.md on `dev` is the one the + # repo's front page shows). `dev` only allows squash merges, so this lands as + # one commit with `production`'s content but not its history. That is fine: + # promote.yml merges `production` into every release branch, which is what + # keeps the next release PR mergeable. back-merge: - needs: release-please-prod - if: needs.release-please-prod.outputs.release_created == 'true' + needs: release + if: needs.release.outputs.release_created == 'true' runs-on: ubuntu-latest permissions: contents: write @@ -88,9 +118,8 @@ jobs: env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} steps: - # See release-please-prod for why this prefers an App token: a PR opened by - # GITHUB_TOKEN does not trigger the required checks, and `dev`'s `Require - # CI` ruleset has NO bypass actors, so such a PR can never be merged. + # Prefers the App token: a PR opened by GITHUB_TOKEN does not trigger the + # required checks, so it could only be merged by an admin bypass. - uses: actions/create-github-app-token@v3 id: app-token if: env.BOT_APP_ID != '' @@ -107,7 +136,7 @@ jobs: - name: Open the back-merge PR env: GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.release-please-prod.outputs.version }} + VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail BRANCH="chore/back-merge-${VERSION}" @@ -116,9 +145,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" - # A merge commit, never a squash or rebase: it is what keeps `dev` and - # `production` ancestors of each other. Rewriting the commits here is - # what left the two branches permanently divergent before 5.5.0. + # A merge, so the PR's diff is exactly what `production` changed. if ! git merge --no-ff origin/production -m "chore: merge production into dev after ${VERSION}"; then echo "Back-merge of production into dev conflicts — resolve it by hand." >&2 git merge --abort || true @@ -135,4 +162,4 @@ jobs: --base dev \ --head "$BRANCH" \ --title "chore: merge production into dev after ${VERSION}" \ - --body "Back-merge of the ${VERSION} stable release so \`dev\` carries the released package.json, CHANGELOG.md and manifest. Opened automatically by the Release workflow." + --body "Back-merge of the ${VERSION} stable release so \`dev\` carries the released package.json, server.json and CHANGELOG.md. Squash-merge it (the only method \`dev\` allows). Opened automatically by the Release workflow." diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index c99ba65..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "5.12.0" -} diff --git a/CLAUDE.md b/CLAUDE.md index 2e2a5a9..c44f10e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,40 +64,34 @@ Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones tha - ⚠️ **This repo is public — write PR titles and bodies, commit messages, code comments, test names and issues for a stranger.** Work here is often prompted by a support ticket or incident in the private `dcd` repo; that context stays there. Keep out: customer or org names and ids, ticket refs (`SUP-…`), private-repo issue/PR numbers, customer usage numbers, internal hostnames, Supabase project refs and dashboard links, and anything about our own security posture — key rotations or revocations, when they happened, which CLI versions they broke. Describe the fault generically ("a dropped B2 request failed the upload") and link the private issue from the private side instead. Edits don't undo a leak: GitHub keeps every revision of a PR or issue body, and a pushed commit stays readable from the PR after a later commit fixes it, so get it right before the first push. - **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly. -- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it. +- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what the release reads to compute the next version and the changelog entry (`scripts/stable-release.mjs`), so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it. - Type → bump: `feat` **minor**; `fix`/`perf`/`deps`/`revert`/`refactor` **patch**; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form. -- ⚠️ **A `!` (or `BREAKING CHANGE:` footer) bumps the MAJOR — do not use it casually.** The configs set `bump-minor-pre-major: true`, but that only applies **below 1.0.0**; we are on 5.x, so it is inert and a breaking marker means exactly what semver says. A `refactor(cloud)!:` PR title once produced a `6.0.0-beta.1` release PR for what was only a flag rename in an unconsumed beta. Because PRs are squash-merged, **the PR title IS the commit** — the `!` lands even if no branch commit carried it. -- **Never hand-edit `package.json` version, `CHANGELOG.md`, or `.release-please-manifest.json`** — release-please owns all three, and hand-editing them is what left the 5.6.0 release PR internally inconsistent. (`CHANGELOG-beta.md` is frozen; nothing writes it any more.) `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). +- ⚠️ **A `!` (or `BREAKING CHANGE:` footer) means a MAJOR — do not use it casually.** A `refactor(cloud)!:` PR title once produced a `6.0.0-beta.1` release PR for what was only a flag rename in an unconsumed beta. Because PRs are squash-merged, **the PR title IS the commit** — the `!` lands even if no branch commit carried it. The release never derives a major from one: it stops and lists the breaking commits, and a major has to be given as the version. +- **Never hand-edit the `package.json` or `server.json` version, or `CHANGELOG.md`** — the release commit that `promote.yml` writes owns all three, and hand-editing them is what left the 5.6.0 release PR internally inconsistent. (`CHANGELOG-beta.md` is frozen; nothing writes it any more.) `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). - A first-time contributor must sign the CLA (the CLA Assistant bot comments on the first PR); the CLA check must be green to merge. - **CI (`.github/workflows/cli-ci.yml`) runs the same steps on every PR** — fork, Dependabot and same-repo alike, with no privileged path: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm test:unit`, `pnpm build`, `pnpm audit --audit-level moderate`. **`test/integration/*` is not run by CI at all** (see the Commands section: this public repo no longer reaches into the private `devicecloud-dev/dcd` repo for a mock API), so a green PR says nothing about the integration suite — run it locally with `MOCK_API_DIR` set if a change touches the API surface. gitleaks also runs as a pre-commit hook (allowlist in `.gitleaks.toml`); without the binary installed the hook self-skips and CI is the backstop. ## Releases -**Stable releases are automatic; betas are cut by hand.** +**A stable release is one PR, opened by a workflow; betas are cut by hand.** -| Line | Trigger | Version | npm tag | +| Line | How | Version | npm tag | | --- | --- | --- | --- | -| **stable** | push to `production` -> release-please opens a Release PR; merging it tags, publishes and uploads binaries | `X.Y.Z` | `latest` | -| **beta** | run the **Release beta** workflow (`release-beta.yml`) by hand from `dev` | `X.Y.Z-beta.N` | `beta` | +| **stable** | run **Promote to stable** (`promote.yml`) from `dev`; it opens the release PR into `production`, and merging that tags, publishes and uploads binaries | `X.Y.Z` | `latest` | +| **beta** | run **Release beta** (`release-beta.yml`) by hand from `dev` | `X.Y.Z-beta.N` | `beta` | -Stable is driven by `release-please.yml` off `release-please-config.json` / `.release-please-manifest.json`, writing `CHANGELOG.md`. Merging the Release PR creates the tag + GitHub Release and the same run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into `npm-publish.yml` and `release-binaries.yml`. `get.devicecloud.dev` serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. After a stable release the workflow also opens a **back-merge PR** into `dev`, so the two branches don't drift apart the way they did up to 5.5.0. +**Cutting a stable release.** Run `gh workflow run promote.yml --ref dev`, or use the Actions tab. Leave `version` blank to derive it, or set it. `dry_run: true` builds the branch and shows the version and changelog in the run summary, and pushes nothing. The workflow builds `promote/cli-X.Y.Z`: `origin/dev`, with `origin/production` merged in, plus one `chore: release X.Y.Z` commit that sets the version in `package.json` and `server.json` and adds the `CHANGELOG.md` section. It opens that as a PR into `production`. Merge it with a **merge commit**, the only method `production` allows. That push runs `release-please.yml`. It finds an untagged version on `production`, tags it, and creates the GitHub Release from the changelog section. It then **chains** into `npm-publish.yml` and `release-binaries.yml` as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`. Last, it opens a **back-merge PR** into `dev`. `get.devicecloud.dev` serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. Only one release can be open at a time: the workflow refuses while any PR into `production` is open. To ship something different, close the PR, land the change on `dev`, and run the workflow again. It overwrites its own branch. -Betas have **no release-please track and no manifest**. `release-beta.yml` derives the version from the npm registry (`scripts/next-beta-version.mjs`): it bumps the published `latest` and appends the next `-beta.N`, so a beta is always above the current stable. There was a second release-please track until 5.6.0, and because its manifest numbered itself with no reference to what stable had shipped, `@beta` ended up *older* than `@latest` three times (5.0.0, 5.2.0, 5.5.0) — each needing a hand-written `Release-As` to escape. `CHANGELOG-beta.md` is frozen at 5.6.0-beta.2; beta notes now live on the GitHub prerelease. +`scripts/stable-release.mjs` does the version and changelog work (`plan`, `apply`, `verify`, `notes`). `test/unit/stable-release.test.ts` drives it against throwaway git repos. -**Promoting beta -> stable** is a maintainer opening a PR from `dev` into `production` and merging it with a **merge commit** — that push to `production` is what triggers the stable Release PR. Use a merge commit, never squash or rebase: the merge is what makes `production` a descendant of `dev`, so the two branches stay reconcilable and the next promotion's diff is only the commits since the last one. A squash or rebase promotion rewrites the commits, leaves the histories permanently divergent, and forces the next promotion to be reconstructed by hand — that is exactly what the pre-5.5.0 promotions did. +- **Version.** It is derived from the PR titles of the non-merge commits in `origin/production..origin/dev`, using the type → bump table above. When nothing is releasable, the run fails unless a version is given. A breaking marker **never derives a major**: the run fails and lists the breaking commits. The version must be above every stable tag. +- **Changelog.** It keeps release-please's layout, with one entry per PR. A title cut short with `…` is replaced by the full line from the squash body. release-please listed that line as a second entry. +- **Checks.** `release-check` in `cli-ci.yml` runs `verify` on every PR into `production`. It checks that `package.json`, `server.json` and the newest `CHANGELOG.md` section agree on a version that is untagged and above every stable tag. The Release workflow runs the same check before it tags. A push to `production` whose version is already tagged releases nothing. -### Every promotion must carry a `Release-As:` pin +**Why `production` is merged in every time.** `dev`'s rulesets allow only squash merges with linear history. So the back-merge lands as one commit with `production`'s content but not its history, and `production` never becomes an ancestor of `dev`. The back-merge keeps `dev`'s version files and changelog current. The merge in `promote.yml` is what keeps the release PR mergeable. If that merge conflicts, the workflow stops. That only happens when both branches changed the same lines, for example a hand edit on `production`. Then build the branch by hand the same way: from `origin/dev`, merge `origin/production` and resolve the conflict. Run `node scripts/stable-release.mjs plan --notes-out notes.md`, then `apply --version X.Y.Z --notes notes.md`, and commit `chore: release X.Y.Z`. Push it as `promote/cli-X.Y.Z` and open the PR. -Put the stable version on its own commit in the promotion branch: +**`release-please.yml` keeps its name, although release-please is gone.** npm's trusted publisher is registered against that filename, and npm checks the name of the workflow that started the run. So a rename needs that registration updated on npmjs.com first. release-please needed a promotion PR, then a `Release-As:` pin on it, then its own Release PR. The pin was needed because a promotion makes every beta tag reachable from `production`, and release-please took the newest one as its base. That is how 5.6.0 got a `5.6.0-beta.1` release PR. -``` -git commit --allow-empty -m "chore: pin the X.Y.Z promotion" -m "Release-As: X.Y.Z" -``` +Betas have **no manifest**. `release-beta.yml` derives the version from the npm registry (`scripts/next-beta-version.mjs`). It bumps the published `latest` and appends the next `-beta.N`, so a beta is always above the current stable. Until 5.6.0 there was a release-please beta track. Its manifest numbered itself without reference to what stable had shipped, so `@beta` ended up *older* than `@latest` three times (5.0.0, 5.2.0, 5.5.0). `CHANGELOG-beta.md` is frozen at 5.6.0-beta.2, and beta notes now live on the GitHub prerelease. -It must be an ordinary commit, not the merge commit — release-please's commit splitting is unreliable on merges. The `promotion-pin` check in `cli-ci.yml` enforces this. - -**Do not skip it on the grounds that the conventional commits since the last stable already imply the right bump.** They don't. A promotion is a merge, so every beta tag becomes reachable from `production`, and release-please takes the newest reachable tag as its base — which is a `-beta` one. The 5.6.0 promotion reasoned exactly that way, skipped the pin, and produced a `chore(production): release 5.6.0-beta.1` release PR while leaving `production` carrying `5.6.0-beta.1` in `package.json` against a stable manifest still reading `5.5.0`. `npm-publish.yml`'s version guard then refuses to publish, which is the intended backstop, not the fix. - -Also **keep `production`'s `version` line** when resolving the promotion's `package.json` conflict, not `dev`'s. Taking `dev`'s puts a prerelease on the stable branch for as long as the Release PR is open; release-please rewrites it from the manifest when that PR lands, but if the PR stalls — as 5.6.0's did — the stable branch sits on a `-beta`. - -Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR and the back-merge PR trigger the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured. +The release PR and the back-merge PR are opened with the automation GitHub App token (`BOT_APP_ID`). That way they trigger the CI, PR-title and CLA checks the rulesets require, which a `GITHUB_TOKEN` PR does not. `promote.yml` requires the App token; the back-merge falls back to `GITHUB_TOKEN`. Both PRs skip `claude-review`, which rejects bot actors. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 562769f..7f6a410 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,15 +140,14 @@ deps: bump @modelcontextprotocol/sdk to 1.x You don't need to do anything for releases — **do not bump the version in `package.json` or edit `CHANGELOG.md`** in your PR. -Stable releases are automated by -[release-please](https://github.com/googleapis/release-please): maintainers -promote `dev` → `production` (always with a **merge commit**, so the two branches -stay in sync), which opens a Release PR; merging that publishes to npm under the -`latest` tag. - -release-please reads the Conventional Commit titles of merged PRs to compute the -next version and generate the changelog — which is exactly why the PR title -convention matters. +Stable releases are automated: a maintainer runs the **Promote to stable** +workflow, which opens a release PR from `dev` into `production` with the new +version and changelog already in it. Merging that PR tags the release and +publishes it to npm under the `latest` tag. + +The workflow reads the Conventional Commit titles of merged PRs to work out the +next version and write the changelog, which is why the PR title convention +matters. Betas are published on demand by a maintainer running the **Release beta** workflow, so your change reaches npm's `beta` tag whenever the next one is cut diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index 4869ae1..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "node", - "include-v-in-tag": true, - "include-component-in-tag": false, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false, - "draft": false, - "prerelease": false, - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance" }, - { "type": "deps", "section": "Dependencies" }, - { "type": "revert", "section": "Reverts" }, - { "type": "refactor", "section": "Code Refactoring" }, - { "type": "docs", "section": "Documentation", "hidden": true }, - { "type": "chore", "section": "Miscellaneous", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "style", "section": "Styles", "hidden": true } - ], - "packages": { - ".": { - "package-name": "@devicecloud.dev/dcd", - "changelog-path": "CHANGELOG.md", - "extra-files": [ - { "type": "json", "path": "server.json", "jsonpath": "$.version" }, - { "type": "json", "path": "server.json", "jsonpath": "$.packages[*].version" } - ] - } - } -} diff --git a/scripts/stable-release.mjs b/scripts/stable-release.mjs new file mode 100644 index 0000000..8d32f36 --- /dev/null +++ b/scripts/stable-release.mjs @@ -0,0 +1,467 @@ +#!/usr/bin/env node +/** + * Stable releases: work out the next version, write it, and check it. + * + * A stable release is ONE pull request into `production`, built by + * .github/workflows/promote.yml: `dev`, with `production` merged in, plus a + * commit that sets the version in package.json and server.json and adds the + * CHANGELOG.md section. Merging it is the release: release-please.yml sees an + * untagged version on `production`, tags it, creates the GitHub Release from + * the changelog section, and publishes. + * + * This replaced release-please, which needed a promotion PR, a `Release-As:` + * pin on it (or it based the version on a beta tag), and then a second Release + * PR. Here the version is derived from the same Conventional Commit PR titles, + * in one place, and the release PR is the only PR. + * + * Usage: + * node scripts/stable-release.mjs plan [--version X.Y.Z] [--base ] [--head ] + * [--date YYYY-MM-DD] [--notes-out ] + * node scripts/stable-release.mjs apply --version X.Y.Z --notes + * node scripts/stable-release.mjs verify + * node scripts/stable-release.mjs notes [--version X.Y.Z] + * + * plan Derives the version from the commits in base..head (default + * origin/production..HEAD) and renders the changelog section. Writes + * nothing but --notes-out. Prints the version on stdout. + * apply Sets the version in package.json and server.json, and puts the section + * at the top of CHANGELOG.md, in the working tree. + * verify Checks the working tree is an unreleased, self-consistent release. The + * CI check on PRs into production and the Release workflow both run it. + * notes Prints a version's CHANGELOG.md section (default: package.json's). + * + * plan and verify append key=value lines to $GITHUB_OUTPUT when it is set. + */ +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; + +// Conventional Commit type -> changelog section, in the order the sections are +// printed. A type in a section bumps the patch (feat: the minor); the hidden +// types are left out of the changelog and bump nothing. Keep both lists in +// lockstep with the allowed types in .github/workflows/pr-title-lint.yml. +const SECTIONS = [ + ['feat', 'Features'], + ['fix', 'Bug Fixes'], + ['perf', 'Performance'], + ['deps', 'Dependencies'], + ['revert', 'Reverts'], + ['refactor', 'Code Refactoring'], +]; +const HIDDEN = new Set(['docs', 'chore', 'test', 'ci', 'build', 'style']); +const RELEASABLE = new Set(SECTIONS.map(([type]) => type)); + +// Stable versions only. The stable line never carries a prerelease; betas are +// cut separately by release-beta.yml and never come through here. +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; + +const HEADER = /^(\w+)(?:\(([^()\r\n]+)\))?(!)?: (.+)$/; +const PR_SUFFIX = / \(#(\d+)\)$/; +const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/; + +class ReleaseError extends Error {} + +// GitHub Actions annotations in CI, plain prefixes everywhere else. +const IN_ACTIONS = process.env.GITHUB_ACTIONS === 'true'; +const ERROR = IN_ACTIONS ? '::error::' : 'Error: '; +const WARNING = IN_ACTIONS ? '::warning::' : 'Warning: '; + +// --------------------------------------------------------------------------- +// Versions + +function parseVersion(version) { + const match = STABLE.exec(version ?? ''); + return match ? match.slice(1).map(Number) : null; +} + +function compareVersions(a, b) { + const [pa, pb] = [parseVersion(a), parseVersion(b)]; + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return 0; +} + +function bumpVersion(version, bump) { + const [major, minor, patch] = parseVersion(version); + if (bump === 'major') return `${major + 1}.0.0`; + if (bump === 'minor') return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +// --------------------------------------------------------------------------- +// Commits + +/** + * One commit, as the changelog sees it. PRs are squash-merged, so the subject + * is the PR title: `type(scope)!: description (#123)`. + * + * Long PR titles reach us cut short with an ellipsis. GitHub does that when it + * builds a title from a long commit subject, and again when it builds the + * squash subject from a long title, and puts the uncut text in the body. So a + * subject ending in `…` is replaced by the body line it is the start of, when + * there is one. release-please split those bodies into extra commits instead, + * which listed every such PR twice. + */ +function parseCommit(sha, message) { + const [first = '', ...body] = message.replace(/\r\n/g, '\n').trim().split('\n'); + let subject = first.trim(); + + let pr = null; + const prMatch = PR_SUFFIX.exec(subject); + if (prMatch) { + pr = Number(prMatch[1]); + subject = subject.slice(0, prMatch.index); + } + + if (subject.endsWith('…')) { + const stem = subject.slice(0, -1); + const full = body + .map((line) => line.trim()) + .find((line) => line.length > stem.length && line.startsWith(stem)); + if (full) subject = full.replace(PR_SUFFIX, ''); + } + + const header = HEADER.exec(subject); + if (!header) return { sha, subject, type: null, scope: null, description: subject, pr, breaking: false }; + + const [, type, scope, bang, description] = header; + const breaking = Boolean(bang) || body.some((line) => BREAKING_FOOTER.test(line.trim())); + return { sha, subject, type: type.toLowerCase(), scope: scope ?? null, description, pr, breaking }; +} + +/** null when nothing in `commits` calls for a release. */ +function bumpFor(commits) { + let bump = null; + for (const commit of commits) { + if (commit.breaking) return 'major'; + if (commit.type === 'feat') bump = 'minor'; + else if (!bump && RELEASABLE.has(commit.type)) bump = 'patch'; + } + return bump; +} + +// --------------------------------------------------------------------------- +// Changelog + +function entry(commit, repoUrl) { + const scope = commit.scope ? `**${commit.scope}:** ` : ''; + const pr = commit.pr ? ` ([#${commit.pr}](${repoUrl}/issues/${commit.pr}))` : ''; + return `* ${scope}${commit.description}${pr} ([${commit.sha.slice(0, 7)}](${repoUrl}/commit/${commit.sha}))`; +} + +// Unscoped entries first, then by scope, then by description: the order +// release-please used, so the file reads the same above and below the switch. +function byScopeThenDescription(a, b) { + if (a.scope !== b.scope) { + if (!a.scope) return -1; + if (!b.scope) return 1; + return a.scope.localeCompare(b.scope); + } + return a.description.localeCompare(b.description); +} + +/** + * The CHANGELOG.md section for a release, in release-please's layout (two + * blank lines before each subsection). It is also the GitHub Release body. + */ +function renderSection({ version, previous, date, commits, repoUrl }) { + const groups = []; + const breaking = commits.filter((c) => c.breaking); + if (breaking.length > 0) groups.push(['⚠ BREAKING CHANGES', breaking]); + for (const [type, title] of SECTIONS) { + const inSection = commits.filter((c) => c.type === type); + if (inSection.length > 0) groups.push([title, inSection]); + } + + const lines = [`## [${version}](${repoUrl}/compare/v${previous}...v${version}) (${date})`]; + if (groups.length === 0) { + lines.push('', '', 'No user-facing changes.'); + } + for (const [title, inGroup] of groups) { + lines.push('', '', `### ${title}`, ''); + lines.push(...[...inGroup].sort(byScopeThenDescription).map((c) => entry(c, repoUrl))); + } + return `${lines.join('\n')}\n`; +} + +const SECTION_HEADING = /^## \[([^\]]+)\]/; + +function firstSectionVersion(changelog) { + for (const line of changelog.split('\n')) { + const match = SECTION_HEADING.exec(line); + if (match) return match[1]; + } + return null; +} + +function insertSection(changelog, section) { + const lines = changelog.split('\n'); + const at = lines.findIndex((line) => SECTION_HEADING.test(line)); + if (at === -1) return `${changelog.trimEnd()}\n\n${section}`; + const head = lines.slice(0, at).join('\n'); + const rest = lines.slice(at).join('\n'); + return `${head}${head ? '\n' : ''}${section}\n${rest}`; +} + +function extractSection(changelog, version) { + const lines = changelog.split('\n'); + const start = lines.findIndex((line) => SECTION_HEADING.exec(line)?.[1] === version); + if (start === -1) return null; + let end = lines.findIndex((line, i) => i > start && SECTION_HEADING.test(line)); + if (end === -1) end = lines.length; + return `${lines.slice(start, end).join('\n').trimEnd()}\n`; +} + +// --------------------------------------------------------------------------- +// Version files + +/** + * Rewrites the version strings in a JSON file without reformatting it: a + * textual replace of `"version": ""`, checked against the same edit + * made on the parsed object, so a stray match or a missed field fails loudly. + */ +function rewriteJsonVersions(path, previous, version, setVersions) { + const text = readFileSync(path, 'utf8'); + const expected = JSON.parse(text); + setVersions(expected, version); + + const pattern = new RegExp(`("version"\\s*:\\s*")${previous.replaceAll('.', '\\.')}"`, 'g'); + const rewritten = text.replace(pattern, `$1${version}"`); + if (!isDeepStrictEqual(JSON.parse(rewritten), expected)) { + throw new ReleaseError( + `${path}: rewriting "version": "${previous}" to ${version} did not produce the expected file. ` + + `Its version fields must all read ${previous} before a release.`, + ); + } + writeFileSync(path, rewritten); +} + +const setPackageVersion = (pkg, version) => { + pkg.version = version; +}; + +// server.json is the MCP registry manifest: its own version and every npm +// package entry's version track the release. Nothing else in it may carry a +// version (see test/unit/server-json.test.ts). +const setServerVersions = (server, version) => { + server.version = version; + for (const pkg of server.packages ?? []) pkg.version = version; +}; + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +// --------------------------------------------------------------------------- +// Git + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} + +function tagExists(tag) { + try { + git('rev-parse', '--quiet', '--verify', `refs/tags/${tag}^{commit}`); + return true; + } catch { + return false; + } +} + +function highestStableTag() { + const versions = git('tag', '--list', 'v*') + .split('\n') + .map((tag) => tag.trim().replace(/^v/, '')) + .filter((v) => parseVersion(v)); + return versions.sort(compareVersions).at(-1) ?? null; +} + +const RECORD = '\u001E'; +const FIELD = '\u001F'; + +/** Non-merge commits in base..head, oldest first. */ +function commitsBetween(base, head) { + return git('log', '--no-merges', '--reverse', `--format=%H${FIELD}%B${RECORD}`, `${base}..${head}`) + .split(RECORD) + .map((record) => record.trim()) + .filter(Boolean) + .map((record) => { + const [sha, message = ''] = record.split(FIELD); + return parseCommit(sha.trim(), message); + }); +} + +function repoUrl(pkg) { + const url = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url; + if (!url) throw new ReleaseError('package.json has no repository url to link the changelog to'); + return url.replace(/^git\+/, '').replace(/\.git$/, ''); +} + +// --------------------------------------------------------------------------- +// Commands + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? '' : (process.argv[i + 1] ?? '').trim(); +} + +function output(values) { + if (!process.env.GITHUB_OUTPUT) return; + appendFileSync( + process.env.GITHUB_OUTPUT, + Object.entries(values) + .map(([key, value]) => `${key}=${value}\n`) + .join(''), + ); +} + +function describe(commit) { + return ` ${commit.sha.slice(0, 7)} ${commit.subject}${commit.pr ? ` (#${commit.pr})` : ''}`; +} + +function plan() { + const base = arg('base') || 'origin/production'; + const head = arg('head') || 'HEAD'; + const requested = arg('version'); + const date = arg('date') || new Date().toISOString().slice(0, 10); + + const basePackage = JSON.parse(git('show', `${base}:package.json`)); + const previous = basePackage.version; + if (!parseVersion(previous)) { + throw new ReleaseError(`${base} is on "${previous}", which is not a stable X.Y.Z version`); + } + if (!tagExists(`v${previous}`)) { + throw new ReleaseError(`${base} is on ${previous}, but there is no v${previous} tag to release on top of`); + } + + const commits = commitsBetween(base, head); + const bump = bumpFor(commits); + + let version; + if (requested) { + if (!parseVersion(requested)) throw new ReleaseError(`"${requested}" is not a stable X.Y.Z version`); + version = requested; + } else if (!bump) { + throw new ReleaseError( + `nothing to release: no ${[...RELEASABLE].join('/')} commits in ${base}..${head}. ` + + 'Pass a version to release anyway.', + ); + } else if (bump === 'major') { + // CLAUDE.md: a `!` in a PR title once turned a flag rename into a 6.0.0 + // release PR. A major is never derived; it has to be asked for. + const breaking = commits.filter((c) => c.breaking).map(describe).join('\n'); + throw new ReleaseError( + `these commits are marked breaking, which calls for ${bumpVersion(previous, 'major')}:\n${breaking}\n` + + 'A major is never derived. If it is intended, pass the version explicitly.', + ); + } else { + version = bumpVersion(previous, bump); + } + + if (compareVersions(version, previous) <= 0) { + throw new ReleaseError(`${version} is not above ${previous}, the version ${base} is on`); + } + const highest = highestStableTag(); + if (highest && compareVersions(version, highest) <= 0) { + throw new ReleaseError(`${version} is not above v${highest}, the highest stable tag`); + } + if (tagExists(`v${version}`)) throw new ReleaseError(`tag v${version} already exists`); + + const section = renderSection({ version, previous, date, commits, repoUrl: repoUrl(basePackage) }); + const notesOut = arg('notes-out'); + if (notesOut) writeFileSync(notesOut, section); + + const listed = commits.filter((c) => RELEASABLE.has(c.type) || c.breaking); + const hidden = commits.filter((c) => HIDDEN.has(c.type) && !c.breaking); + const unparsed = commits.filter((c) => !c.type); + console.error(`${previous} -> ${version} (${requested ? 'requested' : bump}), ${base}..${head}`); + console.error(`In the changelog (${listed.length}):\n${listed.map(describe).join('\n') || ' none'}`); + if (hidden.length > 0) console.error(`Left out, hidden types (${hidden.length}):\n${hidden.map(describe).join('\n')}`); + if (unparsed.length > 0) { + console.error(`${WARNING}Left out, not Conventional Commits (${unparsed.length}):\n${unparsed.map(describe).join('\n')}`); + } + + console.log(version); + output({ version, previous, bump: requested ? 'requested' : bump, tag: `v${version}` }); +} + +function apply() { + const version = arg('version'); + const notes = arg('notes'); + if (!parseVersion(version)) throw new ReleaseError('apply needs --version X.Y.Z'); + if (!notes) throw new ReleaseError('apply needs --notes (from plan --notes-out)'); + + const previous = readJson('package.json').version; + const section = readFileSync(notes, 'utf8'); + if (firstSectionVersion(section) !== version) { + throw new ReleaseError(`${notes} is not the changelog section for ${version}`); + } + + rewriteJsonVersions('package.json', previous, version, setPackageVersion); + rewriteJsonVersions('server.json', previous, version, setServerVersions); + writeFileSync('CHANGELOG.md', insertSection(readFileSync('CHANGELOG.md', 'utf8'), section)); + console.error(`package.json, server.json and CHANGELOG.md now carry ${version}`); +} + +function verify() { + const version = readJson('package.json').version; + const problems = []; + + if (!parseVersion(version)) problems.push(`package.json is on "${version}", not a stable X.Y.Z version`); + + const server = readJson('server.json'); + const serverVersions = [server.version, ...(server.packages ?? []).map((p) => p.version)]; + if (serverVersions.some((v) => v !== version)) { + problems.push(`server.json versions (${serverVersions.join(', ')}) do not all match package.json's ${version}`); + } + + const changelogVersion = firstSectionVersion(readFileSync('CHANGELOG.md', 'utf8')); + if (changelogVersion !== version) { + problems.push(`CHANGELOG.md's newest section is ${changelogVersion ?? 'missing'}, not ${version}`); + } + + if (parseVersion(version)) { + if (tagExists(`v${version}`)) { + problems.push(`v${version} is already tagged, so this would release nothing`); + } else { + const highest = highestStableTag(); + if (highest && compareVersions(version, highest) <= 0) { + problems.push(`${version} is not above v${highest}, the highest stable tag`); + } + } + } + + if (problems.length > 0) { + throw new ReleaseError( + `not a releasable stable version:\n${problems.map((p) => ` - ${p}`).join('\n')}\n` + + 'Stable releases are built by the "Promote to stable" workflow (.github/workflows/promote.yml).', + ); + } + console.error(`${version} is ready to release`); + console.log(version); + output({ version, tag: `v${version}` }); +} + +function notes() { + const version = arg('version') || readJson('package.json').version; + const section = extractSection(readFileSync('CHANGELOG.md', 'utf8'), version); + if (!section) throw new ReleaseError(`CHANGELOG.md has no section for ${version}`); + process.stdout.write(section); +} + +const COMMANDS = { plan, apply, verify, notes }; +const command = COMMANDS[process.argv[2]]; +if (!command) { + console.error(`Usage: node scripts/stable-release.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); + process.exit(2); +} +try { + command(); +} catch (error) { + if (!(error instanceof ReleaseError)) throw error; + console.error(`${ERROR}${error.message.split('\n')[0]}`); + const rest = error.message.split('\n').slice(1).join('\n'); + if (rest) console.error(rest); + process.exit(1); +} diff --git a/test/unit/server-json.test.ts b/test/unit/server-json.test.ts index d3b711e..f0e51db 100644 --- a/test/unit/server-json.test.ts +++ b/test/unit/server-json.test.ts @@ -11,7 +11,8 @@ import { readFileSync } from 'node:fs'; * The runtime arguments end with a bare `--package`, so the * `@` the client appends becomes its value, and * `dcd-mcp` follows as the package argument. The pin therefore lives only in - * `packages[].version`, a bare version string release-please can rewrite. + * `packages[].version`, a bare version string the release can rewrite + * (scripts/stable-release.mjs, tested in stable-release.test.ts). */ interface Argument { name?: string; @@ -30,13 +31,6 @@ interface ServerJson { version: string; } -interface ReleasePleaseConfig { - packages: Record< - string, - { 'extra-files'?: Array<{ jsonpath?: string; path: string; type: string }> } - >; -} - const readJson = (file: string): T => JSON.parse(readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8')) as T; @@ -70,29 +64,17 @@ describe('server.json (MCP registry manifest)', () => { }); it('pins the last stable release, and only in the bare version fields', () => { - const { '.': stable } = readJson>( - '.release-please-manifest.json', - ); + const { version: stable } = readJson<{ version: string }>('package.json'); expect(server.version).to.equal(stable); expect(npmPackage.version).to.equal(stable); - // A version embedded in an argument can't be kept current: release-please's - // json updater replaces the whole matched value with the bare version. + // A version embedded in an argument can't be kept current: the release + // only rewrites bare `"version": "X.Y.Z"` fields. const argv = [ ...render(npmPackage.runtimeArguments), ...render(npmPackage.packageArguments), ]; expect(argv.filter((a) => /@\d/.test(a))).to.deep.equal([]); }); - - it('only lets release-please rewrite bare version fields in server.json', () => { - const config = readJson('release-please-config.json'); - const serverJsonPaths = Object.values(config.packages) - .flatMap((pkg) => pkg['extra-files'] ?? []) - .filter((file) => file.path === 'server.json') - .map((file) => file.jsonpath); - - expect(serverJsonPaths).to.have.members(['$.version', '$.packages[*].version']); - }); }); diff --git a/test/unit/stable-release.test.ts b/test/unit/stable-release.test.ts new file mode 100644 index 0000000..fd6ab8d --- /dev/null +++ b/test/unit/stable-release.test.ts @@ -0,0 +1,297 @@ +import { expect } from 'chai'; +import { execFileSync, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * scripts/stable-release.mjs works out, writes and checks a stable release + * (.github/workflows/promote.yml and release-please.yml). It is driven here as + * those workflows drive it: as a subprocess, inside a throwaway git repo with a + * `production` branch tagged at the last release and a `dev` branch ahead of it. + */ +const ROOT = fileURLToPath(new URL('../../', import.meta.url)); +const SCRIPT = path.join(ROOT, 'scripts/stable-release.mjs'); +const REPO = 'https://github.com/devicecloud-dev/dcd-cli'; + +// git without the developer's own config: no signing, hooks or default branch. +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_AUTHOR_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: '1', +}; + +const PACKAGE_JSON = `{ + "name": "@devicecloud.dev/dcd", + "repository": { + "type": "git", + "url": "git+${REPO}.git" + }, + "version": "1.4.0", + "devDependencies": { + "pinned": "1.4.0" + } +} +`; + +const SERVER_JSON = `{ + "name": "dev.devicecloud/dcd", + "version": "1.4.0", + "packages": [ + { + "identifier": "@devicecloud.dev/dcd", + "version": "1.4.0" + } + ] +} +`; + +const OLD_SECTION = `## [1.4.0](${REPO}/compare/v1.3.0...v1.4.0) (2026-01-01) + + +### Features + +* an older feature +`; + +describe('scripts/stable-release.mjs', () => { + let dir: string; + let outputFile: string; + + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: dir, encoding: 'utf8', env: GIT_ENV }); + const commit = (message: string): string => { + git('commit', '--allow-empty', '--quiet', '-m', message); + return git('rev-parse', 'HEAD').trim(); + }; + const read = (file: string): string => fs.readFileSync(path.join(dir, file), 'utf8'); + const write = (file: string, text: string): void => + fs.writeFileSync(path.join(dir, file), text); + + const run = (...args: string[]) => { + fs.writeFileSync(outputFile, ''); + const result = spawnSync(process.execPath, [SCRIPT, ...args], { + cwd: dir, + encoding: 'utf8', + env: { ...GIT_ENV, GITHUB_ACTIONS: '', GITHUB_OUTPUT: outputFile }, + }); + return { ...result, outputs: fs.readFileSync(outputFile, 'utf8') }; + }; + const plan = (...args: string[]) => + run('plan', '--base', 'production', '--date', '2026-11-09', '--notes-out', 'notes.md', ...args); + + const link = (sha: string) => `([${sha.slice(0, 7)}](${REPO}/commit/${sha}))`; + const pr = (n: number) => `([#${n}](${REPO}/issues/${n}))`; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-stable-release-')); + outputFile = path.join(dir, '.github-output'); + git('init', '--quiet', '--initial-branch', 'production'); + write('.gitignore', 'notes.md\n.github-output\n'); + write('package.json', PACKAGE_JSON); + write('server.json', SERVER_JSON); + write('CHANGELOG.md', `# Changelog\n\n${OLD_SECTION}`); + git('add', '.'); + commit('chore: release 1.4.0'); + git('tag', 'v1.4.0'); + git('checkout', '--quiet', '-b', 'dev'); + }); + + afterEach(() => { + fs.rmSync(dir, { force: true, recursive: true }); + }); + + it('derives a minor from a feat and lists each releasable PR once', () => { + const fix = commit('fix(cloud): retry a dropped upload (#11)'); + const feat = commit('feat: add a --quiet flag (#12)'); + commit('chore: tidy the scripts (#13)'); + commit('docs: explain the flag'); + + const result = plan(); + + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout).to.equal('1.5.0\n'); + expect(result.outputs).to.include('version=1.5.0\n').and.include('previous=1.4.0\n').and.include('bump=minor\n'); + expect(read('notes.md')).to.equal( + [ + `## [1.5.0](${REPO}/compare/v1.4.0...v1.5.0) (2026-11-09)`, + '', + '', + '### Features', + '', + `* add a --quiet flag ${pr(12)} ${link(feat)}`, + '', + '', + '### Bug Fixes', + '', + `* **cloud:** retry a dropped upload ${pr(11)} ${link(fix)}`, + '', + ].join('\n'), + ); + // plan writes nothing but the notes. + expect(read('package.json')).to.equal(PACKAGE_JSON); + }); + + it("derives a patch from dev's own commits, leaving out production's and merges", () => { + commit('fix: handle an empty flow directory (#21)'); + commit('deps: bump the minor-and-patch group (#22)'); + git('checkout', '--quiet', 'production'); + commit('feat: a commit that only production has'); + git('checkout', '--quiet', 'dev'); + git('merge', '--quiet', '--no-ff', '-m', 'Merge branch production into dev', 'production'); + + const result = plan(); + + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout).to.equal('1.4.1\n'); + expect(read('notes.md')) + .to.include('### Bug Fixes') + .and.include('### Dependencies') + .and.not.include('only production has') + .and.not.include('Merge branch'); + }); + + it('uses the untruncated title from the body when the subject was cut short', () => { + commit( + 'feat(report): deprecate allure and html-detailed, removed on 7 Decemb… (#298)\n\n' + + 'feat(report): deprecate allure and html-detailed, removed on 7 December 2026\n\n' + + 'The allure and html-detailed report formats are deprecated.', + ); + + const result = plan(); + + expect(result.status, result.stderr).to.equal(0); + expect(read('notes.md')) + .to.include(`* **report:** deprecate allure and html-detailed, removed on 7 December 2026 ${pr(298)}`) + .and.not.include('…'); + }); + + it('never derives a major, but releases one that is asked for', () => { + commit('refactor(cloud)!: rename --flows to --flow-dir (#30)'); + + const derived = plan(); + expect(derived.status).to.equal(1); + expect(derived.stderr).to.include('2.0.0').and.include('rename --flows'); + expect(fs.existsSync(path.join(dir, 'notes.md'))).to.equal(false); + + const requested = plan('--version', '2.0.0'); + expect(requested.status, requested.stderr).to.equal(0); + expect(requested.outputs).to.include('bump=requested\n'); + expect(read('notes.md')) + .to.include('### ⚠ BREAKING CHANGES\n\n* **cloud:** rename --flows to --flow-dir') + .and.include('### Code Refactoring\n\n* **cloud:** rename --flows to --flow-dir'); + }); + + it('treats a BREAKING CHANGE footer as breaking too', () => { + commit('fix: drop the legacy upload path (#31)\n\nBREAKING CHANGE: CLIs older than 5.9.0 cannot upload.'); + + expect(plan().stderr).to.include('2.0.0'); + }); + + it('refuses a release with nothing in it unless a version is given', () => { + commit('chore: tidy (#40)'); + commit('ci: cache pnpm (#41)'); + + const derived = plan(); + expect(derived.status).to.equal(1); + expect(derived.stderr).to.include('nothing to release'); + + const requested = plan('--version', '1.4.1'); + expect(requested.status, requested.stderr).to.equal(0); + expect(read('notes.md')).to.include('No user-facing changes.'); + }); + + it('refuses a version that is not new', () => { + commit('feat: something (#50)'); + + expect(plan('--version', '1.4.0').stderr).to.include('not above 1.4.0'); + expect(plan('--version', '1.5.0-beta.1').stderr).to.include('not a stable X.Y.Z version'); + + git('tag', 'v1.6.0'); + expect(plan('--version', '1.5.0').stderr).to.include('not above v1.6.0'); + expect(plan('--version', '1.6.0').stderr).to.include('not above v1.6.0'); + }); + + it('writes the release into the version files and changelog, which verify accepts', () => { + commit('feat: add a --quiet flag (#12)'); + expect(plan().status).to.equal(0); + + const apply = run('apply', '--version', '1.5.0', '--notes', 'notes.md'); + expect(apply.status, apply.stderr).to.equal(0); + + expect(read('package.json')).to.equal(PACKAGE_JSON.replace('"version": "1.4.0"', '"version": "1.5.0"')); + expect(read('server.json')).to.equal(SERVER_JSON.replaceAll('1.4.0', '1.5.0')); + expect(read('CHANGELOG.md')).to.equal(`# Changelog\n\n${read('notes.md')}\n${OLD_SECTION}`); + + const verify = run('verify'); + expect(verify.status, verify.stderr).to.equal(0); + expect(verify.outputs).to.include('version=1.5.0\n').and.include('tag=v1.5.0\n'); + + expect(run('notes').stdout).to.equal(read('notes.md')); + expect(run('notes', '--version', '1.4.0').stdout).to.equal(OLD_SECTION); + expect(run('notes', '--version', '1.3.0').status).to.equal(1); + }); + + it('verify rejects a tree that would not release cleanly', () => { + const released = run('verify'); + expect(released.status).to.equal(1); + expect(released.stderr).to.include('v1.4.0 is already tagged'); + + commit('feat: add a --quiet flag (#12)'); + plan(); + run('apply', '--version', '1.5.0', '--notes', 'notes.md'); + write('server.json', SERVER_JSON.replace('"version": "1.4.0"', '"version": "1.5.0"')); + write('CHANGELOG.md', `# Changelog\n\n${OLD_SECTION}`); + + const stale = run('verify'); + expect(stale.status).to.equal(1); + expect(stale.stderr).to.include('server.json versions (1.5.0, 1.4.0)').and.include("newest section is 1.4.0, not 1.5.0"); + }); + + it('refuses to apply when a version field it should not touch matches', () => { + write('package.json', PACKAGE_JSON.replace('"pinned": "1.4.0"', '"version": "1.4.0"')); + git('commit', '--quiet', '--all', '-m', 'chore: a nested version field'); + commit('feat: something (#60)'); + plan(); + + const apply = run('apply', '--version', '1.5.0', '--notes', 'notes.md'); + expect(apply.status).to.equal(1); + expect(apply.stderr).to.include('package.json'); + }); + + it("changes only the version fields of the repo's own package.json and server.json", () => { + const realPackage = fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'); + const realServer = fs.readFileSync(path.join(ROOT, 'server.json'), 'utf8'); + const current = (JSON.parse(realPackage) as { version: string }).version; + const [major, minor] = current.split('.').map(Number); + const next = `${major}.${minor + 1}.0`; + + git('checkout', '--quiet', 'production'); + write('package.json', realPackage); + write('server.json', realServer); + git('commit', '--quiet', '--all', '-m', `chore: release ${current}`); + git('tag', `v${current}`); + git('checkout', '--quiet', 'dev'); + git('merge', '--quiet', 'production'); + commit('feat: something (#70)'); + + expect(plan().stdout).to.equal(`${next}\n`); + const apply = run('apply', '--version', next, '--notes', 'notes.md'); + expect(apply.status, apply.stderr).to.equal(0); + + const changed = (before: string, after: string) => { + const lines = after.split('\n'); + return before.split('\n').flatMap((line, i) => (line === lines[i] ? [] : [lines[i].trim()])); + }; + expect(changed(realPackage, read('package.json'))).to.deep.equal([`"version": "${next}",`]); + expect(changed(realServer, read('server.json'))).to.deep.equal([ + `"version": "${next}",`, + `"version": "${next}",`, + ]); + }); +}); From c28c9839bfb2847af05ca9f99ccb7adf67a05015 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Mon, 9 Nov 2026 15:23:15 +0000 Subject: [PATCH 2/2] ci: keep the stable release PR open and current on every push to dev Promote to stable now also runs on every push to dev. It rebuilds one fixed branch, promote/cli-next, and opens the release PR into production or updates the open one, so the PR always carries all of dev with the version and changelog to match. Nothing ships until someone merges it. - A push with nothing releasable since the last release (docs, the back-merge) does nothing. - A version set by hand is kept on later runs (plan --keep) whenever it is above the derived one, so a major has to be given only once, and a breaking marker still never derives one. - If production moved during the run, nothing is pushed; the back-merge push rebuilds the PR. The force-push is leased against the branch tip the run saw, and skipped when the content is unchanged. - A PR into production from any other branch is left alone: push runs warn and stop. --- .github/workflows/promote.yml | 129 +++++++++++++++++++++---------- CLAUDE.md | 12 +-- CONTRIBUTING.md | 8 +- scripts/stable-release.mjs | 62 +++++++++++---- test/unit/stable-release.test.ts | 35 +++++++++ 5 files changed, 182 insertions(+), 64 deletions(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index dba822d..8211a5a 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -1,27 +1,33 @@ name: Promote to stable -# Turns `dev` into a stable release PR in one step. Run it from the Actions tab -# or with `gh workflow run promote.yml --ref dev`. +# Keeps a stable release PR open and current. It runs on every push to `dev`, +# and by hand from the Actions tab or `gh workflow run promote.yml --ref dev`. # -# It builds `promote/cli-X.Y.Z`: `dev`, with `production` merged in, plus one +# It builds `promote/cli-next`: `dev`, with `production` merged in, plus one # commit that sets the version in package.json and server.json and adds the -# CHANGELOG.md section. Then it opens that branch as a PR into `production`. -# Merging the PR (with a merge commit, the only method `production` allows) is -# the release: release-please.yml tags it, creates the GitHub Release, publishes -# npm `latest` and the binaries, and opens the back-merge PR into `dev`. +# CHANGELOG.md section. It opens that branch as a PR into `production`, or +# updates the PR already open, so the release PR always carries everything on +# `dev`. Nothing ships until someone merges it (with a merge commit, the only +# method `production` allows). Merging is the release: release-please.yml tags +# it, creates the GitHub Release, publishes npm `latest` and the binaries, and +# opens the back-merge PR into `dev`. # # The version comes from the Conventional Commit PR titles since the last -# stable release (feat -> minor, fix/perf/deps/revert/refactor -> patch). A -# major is never derived: a breaking marker makes the run fail until the -# version is given explicitly. See scripts/stable-release.mjs. +# stable release (feat -> minor, fix/perf/deps/revert/refactor -> patch). When +# nothing since then is releasable, a push run does nothing. A major is never +# derived: a breaking marker fails the run until someone runs this by hand with +# the version, and later runs keep a version set by hand rather than go below +# it. See scripts/stable-release.mjs. # # `production` is merged in because `dev` only allows squash merges, so the # back-merge after a release can never make `production` an ancestor of `dev`. on: + push: + branches: [dev] workflow_dispatch: inputs: version: - description: 'Stable version, e.g. 5.13.0. Leave blank to derive it from the PR titles since the last release.' + description: 'Stable version, e.g. 6.0.0. Leave blank to derive it from the PR titles since the last release.' required: false type: string dry_run: @@ -33,10 +39,15 @@ on: permissions: contents: read +# One run at a time, and only the newest waiting run is kept: it rebuilds from +# the latest `dev`, which covers every push before it. concurrency: group: promote-to-stable cancel-in-progress: false +env: + BRANCH: promote/cli-next + jobs: promote: runs-on: ubuntu-latest @@ -74,34 +85,55 @@ jobs: with: node-version: '24.x' - # One release in flight at a time. A second PR would carry the first's - # commits again under another version. - - name: Check no release PR is open + # The release PR this run updates, if one is open. Any other open PR into + # production was made by hand; this leaves it alone rather than open a + # second release beside it. + - name: Find the open release PR + id: pr env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + EVENT: ${{ github.event_name }} DRY_RUN: ${{ inputs.dry_run }} run: | - open=$(gh pr list --base production --state open --json number,headRefName --jq '.[] | "#\(.number) (\(.headRefName))"' | paste -sd, -) - if [ -n "$open" ]; then - if [ "$DRY_RUN" = "true" ]; then - echo "::warning::A PR into production is already open: ${open}. A real run would stop here." + set -euo pipefail + prs=$(gh pr list --base production --state open --json number,headRefName,title) + other=$(jq -r --arg b "$BRANCH" '.[] | select(.headRefName != $b) | "#\(.number) (\(.headRefName))"' <<<"$prs" | paste -sd, -) + if [ -n "$other" ]; then + message="A PR into production that this workflow did not open is open: ${other}." + if [ "$EVENT" = "push" ]; then + echo "::warning::${message} Not updating the release PR until it is merged or closed." + echo "blocked=true" >> "$GITHUB_OUTPUT" + exit 0 + elif [ "$DRY_RUN" = "true" ]; then + echo "::warning::${message} A real run would stop here." else - echo "::error::A PR into production is already open: ${open}. Merge or close it first." + echo "::error::${message} Merge or close it first." exit 1 fi fi + number=$(jq -r --arg b "$BRANCH" '.[] | select(.headRefName == $b) | .number' <<<"$prs") + keep=$(jq -r --arg b "$BRANCH" '.[] | select(.headRefName == $b) | .title | capture("release (?[0-9]+\\.[0-9]+\\.[0-9]+)$").v // empty' <<<"$prs") + echo "Open release PR: ${number:-none}${keep:+ (${keep})}" + { echo "blocked=false"; echo "number=${number}"; echo "keep=${keep}"; } >> "$GITHUB_OUTPUT" - name: Work out the version and changelog id: plan + if: steps.pr.outputs.blocked != 'true' env: INPUT_VERSION: ${{ inputs.version }} + KEEP: ${{ steps.pr.outputs.keep }} + EVENT: ${{ github.event_name }} run: | args=(--base origin/production --head origin/dev --notes-out "$RUNNER_TEMP/notes.md") if [ -n "$INPUT_VERSION" ]; then args+=(--version "$INPUT_VERSION"); fi + if [ -n "$KEEP" ]; then args+=(--keep "$KEEP"); fi + # A push with nothing releasable (a docs change, the back-merge) is + # not an error; a run started by hand that finds nothing is. + if [ "$EVENT" = "push" ]; then args+=(--skip-if-nothing); fi node scripts/stable-release.mjs plan "${args[@]}" - name: Build the release branch - id: branch + if: steps.plan.outputs.release == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} APP_SLUG: ${{ steps.app-token.outputs.app-slug }} @@ -114,8 +146,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" - branch="promote/cli-${VERSION}" - git checkout -B "$branch" origin/dev + git checkout -B "$BRANCH" origin/dev if git merge-base --is-ancestor origin/production HEAD; then echo "dev already contains production." @@ -132,9 +163,9 @@ jobs: # The same check CI runs on the PR, so a bad branch fails here instead. node scripts/stable-release.mjs verify - echo "name=${branch}" >> "$GITHUB_OUTPUT" - name: Write the PR body + if: steps.plan.outputs.release == 'true' env: VERSION: ${{ steps.plan.outputs.version }} PREVIOUS: ${{ steps.plan.outputs.previous }} @@ -143,11 +174,11 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - if [ "$BUMP" = "requested" ]; then - how="version set by hand; previous release ${PREVIOUS}" - else - how="${BUMP} bump from ${PREVIOUS}, derived from the PR titles" - fi + case "$BUMP" in + requested) how="version set by hand; previous release ${PREVIOUS}" ;; + kept) how="version kept from an earlier run; previous release ${PREVIOUS}" ;; + *) how="${BUMP} bump from ${PREVIOUS}, derived from the PR titles" ;; + esac dev_sha=$(git rev-parse --short origin/dev) { echo "Releases **${VERSION}** to npm \`latest\` (${how})." @@ -156,11 +187,11 @@ jobs: echo echo "---" echo - echo "Opened by the [Promote to stable](${RUN_URL}) workflow: \`dev\` at ${dev_sha}, with \`production\` merged in, plus a commit that sets the version in package.json and server.json and adds the CHANGELOG.md section above." + echo "Kept current by the [Promote to stable](${RUN_URL}) workflow, which rebuilds this branch on every push to \`dev\`: \`dev\` at ${dev_sha}, with \`production\` merged in, plus a commit that sets the version in package.json and server.json and adds the CHANGELOG.md section above." echo - echo "- Merge with **Create a merge commit**, the only method \`production\` allows." + echo "- Merge with **Create a merge commit**, the only method \`production\` allows, when you want to release." echo "- Merging is the release: the Release workflow tags \`v${VERSION}\`, creates the GitHub Release from the section above, publishes npm \`latest\` and the binaries, and opens the back-merge PR into \`dev\`." - echo "- To ship something different, close this PR, land the change on \`dev\`, and run the workflow again." + echo "- Leave it open until then; closing it only means the next push to \`dev\` opens a new one." } > "$RUNNER_TEMP/body.md" { if [ "$DRY_RUN" = "true" ]; then echo "### ${VERSION} (dry run: nothing pushed)"; else echo "### ${VERSION}"; fi @@ -173,17 +204,37 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - # The branch belongs to this workflow, so a leftover from a closed PR for - # the same version is overwritten. - - name: Push the branch and open the release PR - if: ${{ !inputs.dry_run }} + - name: Push the branch and open or update the release PR + if: steps.plan.outputs.release == 'true' && !inputs.dry_run env: GH_TOKEN: ${{ steps.app-token.outputs.token }} VERSION: ${{ steps.plan.outputs.version }} - BRANCH: ${{ steps.branch.outputs.name }} + NUMBER: ${{ steps.pr.outputs.number }} run: | set -euo pipefail - git push --force origin "HEAD:refs/heads/${BRANCH}" - url=$(gh pr create --base production --head "$BRANCH" --title "chore: release ${VERSION}" --body-file "$RUNNER_TEMP/body.md") - echo "Opened ${url}" + # If production moved while this ran, a release just merged and this + # build is stale. Its back-merge into dev starts a fresh run. + if [ "$(git ls-remote origin refs/heads/production | cut -f1)" != "$(git rev-parse origin/production)" ]; then + echo "::notice::production moved during this run, so nothing was pushed. The next push to dev rebuilds the release PR." + exit 0 + fi + + # The branch belongs to this workflow, so it is force-pushed, but only + # over what this run saw: a concurrent change makes the push fail. + seen=$(git rev-parse -q --verify "refs/remotes/origin/${BRANCH}" || true) + if [ -n "$seen" ] && [ -n "$NUMBER" ] && git diff --quiet "$seen" HEAD; then + echo "The release PR #${NUMBER} already has this content; nothing to push." + exit 0 + fi + git push --force-with-lease="refs/heads/${BRANCH}:${seen}" origin "HEAD:refs/heads/${BRANCH}" + + title="chore: release ${VERSION}" + if [ -n "$NUMBER" ]; then + gh pr edit "$NUMBER" --title "$title" --body-file "$RUNNER_TEMP/body.md" + url=$(gh pr view "$NUMBER" --json url --jq .url) + echo "Updated ${url}" + else + url=$(gh pr create --base production --head "$BRANCH" --title "$title" --body-file "$RUNNER_TEMP/body.md") + echo "Opened ${url}" + fi { echo; echo "Release PR: ${url}"; } >> "$GITHUB_STEP_SUMMARY" diff --git a/CLAUDE.md b/CLAUDE.md index c44f10e..570d0d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,22 +73,24 @@ Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones tha ## Releases -**A stable release is one PR, opened by a workflow; betas are cut by hand.** +**A stable release is merging the release PR that a workflow keeps open; betas are cut by hand.** | Line | How | Version | npm tag | | --- | --- | --- | --- | -| **stable** | run **Promote to stable** (`promote.yml`) from `dev`; it opens the release PR into `production`, and merging that tags, publishes and uploads binaries | `X.Y.Z` | `latest` | +| **stable** | every push to `dev` runs **Promote to stable** (`promote.yml`), which keeps one release PR into `production` current; merging it tags, publishes and uploads binaries | `X.Y.Z` | `latest` | | **beta** | run **Release beta** (`release-beta.yml`) by hand from `dev` | `X.Y.Z-beta.N` | `beta` | -**Cutting a stable release.** Run `gh workflow run promote.yml --ref dev`, or use the Actions tab. Leave `version` blank to derive it, or set it. `dry_run: true` builds the branch and shows the version and changelog in the run summary, and pushes nothing. The workflow builds `promote/cli-X.Y.Z`: `origin/dev`, with `origin/production` merged in, plus one `chore: release X.Y.Z` commit that sets the version in `package.json` and `server.json` and adds the `CHANGELOG.md` section. It opens that as a PR into `production`. Merge it with a **merge commit**, the only method `production` allows. That push runs `release-please.yml`. It finds an untagged version on `production`, tags it, and creates the GitHub Release from the changelog section. It then **chains** into `npm-publish.yml` and `release-binaries.yml` as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`. Last, it opens a **back-merge PR** into `dev`. `get.devicecloud.dev` serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. Only one release can be open at a time: the workflow refuses while any PR into `production` is open. To ship something different, close the PR, land the change on `dev`, and run the workflow again. It overwrites its own branch. +**Cutting a stable release.** You don't start one: you merge the open `chore: release X.Y.Z` PR from `promote/cli-next` into `production`. Use a **merge commit**, the only method `production` allows. That push runs `release-please.yml`. It finds an untagged version on `production`, tags it, and creates the GitHub Release from the changelog section. It then **chains** into `npm-publish.yml` and `release-binaries.yml` as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`. Last, it opens a **back-merge PR** into `dev`. `get.devicecloud.dev` serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. To hold a release, just don't merge the PR. Closing it doesn't pause releases, because the next push to `dev` opens a new one. + +**How the release PR is kept current.** `promote.yml` runs on every push to `dev`. It rebuilds `promote/cli-next` from `origin/dev`, with `origin/production` merged in, plus one `chore: release X.Y.Z` commit that sets the version in `package.json` and `server.json` and adds the `CHANGELOG.md` section. Then it force-pushes the branch and opens the PR, or updates the open one's title and body. So the PR always carries all of `dev`, and its version moves up as `feat`s land. A push with nothing releasable since the last release, such as a docs change or the back-merge, does nothing. If `production` moved during the run because a release just merged, the run pushes nothing, and the back-merge's push into `dev` rebuilds the PR. If an open PR into `production` came from another branch, push runs warn and leave it alone. The workflow can also be run by hand (`gh workflow run promote.yml --ref dev`, or the Actions tab) to set the `version`. `dry_run: true` shows the version and changelog in the run summary and pushes nothing. The `CHANGELOG.md` date is the day the PR was last rebuilt. `scripts/stable-release.mjs` does the version and changelog work (`plan`, `apply`, `verify`, `notes`). `test/unit/stable-release.test.ts` drives it against throwaway git repos. -- **Version.** It is derived from the PR titles of the non-merge commits in `origin/production..origin/dev`, using the type → bump table above. When nothing is releasable, the run fails unless a version is given. A breaking marker **never derives a major**: the run fails and lists the breaking commits. The version must be above every stable tag. +- **Version.** It is derived from the PR titles of the non-merge commits in `origin/production..origin/dev`, using the type → bump table above. When nothing is releasable, a push run does nothing, and a run started by hand fails unless a version is given. A breaking marker **never derives a major**: every push run fails and lists the breaking commits. To proceed, run the workflow by hand with the major as `version`. Later runs keep the open PR's version (`--keep`) whenever it is above the derived one, so a version set by hand is never lowered, and a major set once stays. The version must be above every stable tag. - **Changelog.** It keeps release-please's layout, with one entry per PR. A title cut short with `…` is replaced by the full line from the squash body. release-please listed that line as a second entry. - **Checks.** `release-check` in `cli-ci.yml` runs `verify` on every PR into `production`. It checks that `package.json`, `server.json` and the newest `CHANGELOG.md` section agree on a version that is untagged and above every stable tag. The Release workflow runs the same check before it tags. A push to `production` whose version is already tagged releases nothing. -**Why `production` is merged in every time.** `dev`'s rulesets allow only squash merges with linear history. So the back-merge lands as one commit with `production`'s content but not its history, and `production` never becomes an ancestor of `dev`. The back-merge keeps `dev`'s version files and changelog current. The merge in `promote.yml` is what keeps the release PR mergeable. If that merge conflicts, the workflow stops. That only happens when both branches changed the same lines, for example a hand edit on `production`. Then build the branch by hand the same way: from `origin/dev`, merge `origin/production` and resolve the conflict. Run `node scripts/stable-release.mjs plan --notes-out notes.md`, then `apply --version X.Y.Z --notes notes.md`, and commit `chore: release X.Y.Z`. Push it as `promote/cli-X.Y.Z` and open the PR. +**Why `production` is merged in every time.** `dev`'s rulesets allow only squash merges with linear history. So the back-merge lands as one commit with `production`'s content but not its history, and `production` never becomes an ancestor of `dev`. The back-merge keeps `dev`'s version files and changelog current. The merge in `promote.yml` is what keeps the release PR mergeable. If that merge conflicts, the workflow stops. That only happens when both branches changed the same lines, for example a hand edit on `production`. Then build the branch by hand the same way: from `origin/dev`, merge `origin/production` and resolve the conflict. Run `node scripts/stable-release.mjs plan --notes-out notes.md`, then `apply --version X.Y.Z --notes notes.md`, and commit `chore: release X.Y.Z`. Push it as `promote/cli-next` and open the PR. Later push runs fail on the same conflict until it is merged. **`release-please.yml` keeps its name, although release-please is gone.** npm's trusted publisher is registered against that filename, and npm checks the name of the workflow that started the run. So a rename needs that registration updated on npmjs.com first. release-please needed a promotion PR, then a `Release-As:` pin on it, then its own Release PR. The pin was needed because a promotion makes every beta tag reachable from `production`, and release-please took the newest one as its base. That is how 5.6.0 got a `5.6.0-beta.1` release PR. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f6a410..6907aa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,10 +140,10 @@ deps: bump @modelcontextprotocol/sdk to 1.x You don't need to do anything for releases — **do not bump the version in `package.json` or edit `CHANGELOG.md`** in your PR. -Stable releases are automated: a maintainer runs the **Promote to stable** -workflow, which opens a release PR from `dev` into `production` with the new -version and changelog already in it. Merging that PR tags the release and -publishes it to npm under the `latest` tag. +Stable releases are automated. After every merge into `dev`, the **Promote to +stable** workflow updates a release PR from `dev` into `production` that carries +the next version and changelog. When a maintainer merges that PR, the release +is tagged and published to npm under the `latest` tag. The workflow reads the Conventional Commit titles of merged PRs to work out the next version and write the changelog, which is why the PR title convention diff --git a/scripts/stable-release.mjs b/scripts/stable-release.mjs index 8d32f36..6a01af1 100644 --- a/scripts/stable-release.mjs +++ b/scripts/stable-release.mjs @@ -15,7 +15,8 @@ * in one place, and the release PR is the only PR. * * Usage: - * node scripts/stable-release.mjs plan [--version X.Y.Z] [--base ] [--head ] + * node scripts/stable-release.mjs plan [--version X.Y.Z] [--keep X.Y.Z] [--skip-if-nothing] + * [--base ] [--head ] * [--date YYYY-MM-DD] [--notes-out ] * node scripts/stable-release.mjs apply --version X.Y.Z --notes * node scripts/stable-release.mjs verify @@ -24,6 +25,11 @@ * plan Derives the version from the commits in base..head (default * origin/production..HEAD) and renders the changelog section. Writes * nothing but --notes-out. Prints the version on stdout. + * --keep is the version of the release PR already open: a derived + * version never goes below it, so a version set by hand (a major, say) + * survives the automatic rebuilds on later pushes to dev. + * --skip-if-nothing exits 0 with release=false when nothing is + * releasable, instead of failing. * apply Sets the version in package.json and server.json, and puts the section * at the top of CHANGELOG.md, in the working tree. * verify Checks the working tree is an unreleased, self-consistent release. The @@ -320,11 +326,22 @@ function describe(commit) { return ` ${commit.sha.slice(0, 7)} ${commit.subject}${commit.pr ? ` (#${commit.pr})` : ''}`; } +function majorError(commits, previous) { + const breaking = commits.filter((c) => c.breaking).map(describe).join('\n'); + return new ReleaseError( + `these commits are marked breaking, which calls for ${bumpVersion(previous, 'major')}:\n${breaking}\n` + + 'A major is never derived. If it is intended, run "Promote to stable" with that version once; ' + + 'later runs keep it.', + ); +} + function plan() { const base = arg('base') || 'origin/production'; const head = arg('head') || 'HEAD'; const requested = arg('version'); + const keep = arg('keep'); const date = arg('date') || new Date().toISOString().slice(0, 10); + if (keep && !parseVersion(keep)) throw new ReleaseError(`--keep "${keep}" is not a stable X.Y.Z version`); const basePackage = JSON.parse(git('show', `${base}:package.json`)); const previous = basePackage.version; @@ -337,26 +354,39 @@ function plan() { const commits = commitsBetween(base, head); const bump = bumpFor(commits); + const derived = bump && bump !== 'major' ? bumpVersion(previous, bump) : null; + // A kept version only counts while it is still ahead of the base: once its + // release has merged, the open PR it came from is gone anyway. + const kept = keep && compareVersions(keep, previous) > 0 ? keep : null; let version; + let how; if (requested) { if (!parseVersion(requested)) throw new ReleaseError(`"${requested}" is not a stable X.Y.Z version`); version = requested; - } else if (!bump) { - throw new ReleaseError( - `nothing to release: no ${[...RELEASABLE].join('/')} commits in ${base}..${head}. ` + - 'Pass a version to release anyway.', - ); - } else if (bump === 'major') { + how = 'requested'; + } else if (!bump && !kept) { + const message = `nothing to release: no ${[...RELEASABLE].join('/')} commits in ${base}..${head}`; + if (process.argv.includes('--skip-if-nothing')) { + console.error(`${message}.`); + output({ release: 'false' }); + return; + } + throw new ReleaseError(`${message}. Pass a version to release anyway.`); + } else if (kept && (!derived || compareVersions(kept, derived) > 0)) { // CLAUDE.md: a `!` in a PR title once turned a flag rename into a 6.0.0 - // release PR. A major is never derived; it has to be asked for. - const breaking = commits.filter((c) => c.breaking).map(describe).join('\n'); - throw new ReleaseError( - `these commits are marked breaking, which calls for ${bumpVersion(previous, 'major')}:\n${breaking}\n` + - 'A major is never derived. If it is intended, pass the version explicitly.', - ); + // release PR. A major is never derived; it has to be asked for once, and + // is kept from then on. + if (bump === 'major' && compareVersions(kept, bumpVersion(previous, 'major')) < 0) { + throw majorError(commits, previous); + } + version = kept; + how = 'kept'; + } else if (bump === 'major') { + throw majorError(commits, previous); } else { - version = bumpVersion(previous, bump); + version = derived; + how = bump; } if (compareVersions(version, previous) <= 0) { @@ -375,7 +405,7 @@ function plan() { const listed = commits.filter((c) => RELEASABLE.has(c.type) || c.breaking); const hidden = commits.filter((c) => HIDDEN.has(c.type) && !c.breaking); const unparsed = commits.filter((c) => !c.type); - console.error(`${previous} -> ${version} (${requested ? 'requested' : bump}), ${base}..${head}`); + console.error(`${previous} -> ${version} (${how}), ${base}..${head}`); console.error(`In the changelog (${listed.length}):\n${listed.map(describe).join('\n') || ' none'}`); if (hidden.length > 0) console.error(`Left out, hidden types (${hidden.length}):\n${hidden.map(describe).join('\n')}`); if (unparsed.length > 0) { @@ -383,7 +413,7 @@ function plan() { } console.log(version); - output({ version, previous, bump: requested ? 'requested' : bump, tag: `v${version}` }); + output({ release: 'true', version, previous, bump: how, tag: `v${version}` }); } function apply() { diff --git a/test/unit/stable-release.test.ts b/test/unit/stable-release.test.ts index fd6ab8d..a632dd1 100644 --- a/test/unit/stable-release.test.ts +++ b/test/unit/stable-release.test.ts @@ -206,6 +206,41 @@ describe('scripts/stable-release.mjs', () => { expect(read('notes.md')).to.include('No user-facing changes.'); }); + it('skips quietly with --skip-if-nothing, as a push run does', () => { + commit('docs: explain the flag (#42)'); + + const result = plan('--skip-if-nothing'); + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout).to.equal(''); + expect(result.outputs).to.equal('release=false\n'); + expect(fs.existsSync(path.join(dir, 'notes.md'))).to.equal(false); + }); + + it("keeps the open release PR's version unless the commits now call for more", () => { + commit('fix: handle an empty flow directory (#43)'); + + // A version set by hand, above what the commits call for, survives. + const kept = plan('--keep', '1.6.0'); + expect(kept.stdout).to.equal('1.6.0\n'); + expect(kept.outputs).to.include('bump=kept\n'); + + // A kept version equal to or below the derived one gives way to it. + expect(plan('--keep', '1.4.1').outputs).to.include('version=1.4.1\n').and.include('bump=patch\n'); + commit('feat: add a --quiet flag (#44)'); + expect(plan('--keep', '1.4.1').stdout).to.equal('1.5.0\n'); + + // One that is no longer ahead of production (its release merged) is ignored. + expect(plan('--keep', '1.4.0').stdout).to.equal('1.5.0\n'); + }); + + it('keeps a major once it has been set by hand, but never derives one', () => { + commit('refactor(cloud)!: rename --flows to --flow-dir (#45)'); + + expect(plan('--keep', '1.5.0').status).to.equal(1); + expect(plan('--keep', '2.0.0').stdout).to.equal('2.0.0\n'); + expect(plan('--keep', '2.0.0', '--skip-if-nothing').stdout).to.equal('2.0.0\n'); + }); + it('refuses a version that is not new', () => { commit('feat: something (#50)');