From efcfbf95fb7c0356aab173431bec007868088a0e Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:17:42 +0100 Subject: [PATCH] ci: cut betas by hand and require a Release-As pin on promotions Betas move from a second release-please track on `dev` to a dispatched `Release beta` workflow that derives the version from the npm registry, so a beta can no longer sort below `@latest` the way 5.0.0, 5.2.0 and 5.5.0 did. The beta config, manifest and npm-publish beta path go with it; CHANGELOG-beta is frozen at 5.6.0-beta.2. Stable is unchanged apart from a new back-merge PR after each release, and a `promotion-pin` CI check: a promotion is a merge, so every beta tag is reachable from production and release-please picks one as its base unless the stable version is pinned. That is what left production on 5.6.0-beta.1. Publishing now happens before the tag and GitHub release are created, so a failed publish no longer strands a release with no package on npm. --- .github/workflows/cli-ci.yml | 66 +++++++++ .github/workflows/npm-publish.yml | 84 ++++------- .github/workflows/release-beta.yml | 194 +++++++++++++++++++++++++ .github/workflows/release-binaries.yml | 14 +- .github/workflows/release-please.yml | 132 +++++++++-------- .release-please-manifest-beta.json | 3 - CHANGELOG-beta.md | 6 + CLAUDE.md | 36 +++-- CONTRIBUTING.md | 20 ++- release-please-config-beta.json | 32 ---- scripts/next-beta-version.mjs | 127 ++++++++++++++++ 11 files changed, 541 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/release-beta.yml delete mode 100644 .release-please-manifest-beta.json delete mode 100644 release-please-config-beta.json create mode 100644 scripts/next-beta-version.mjs diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 539f1c1..3ec9ff9 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -101,3 +101,69 @@ jobs: - name: Security audit 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. + promotion-pin: + if: github.event_name == 'pull_request' && github.base_ref == 'production' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + 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 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index ce9165d..b2a74b7 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,22 +1,23 @@ 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`. +# +# 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 +# with the beta release-please track gone its beta path would have published +# whatever version `dev`'s package.json happened to carry (the last stable) under +# the `beta` dist-tag. A second, subtly-wrong way to publish a beta is worth more +# trouble than it saves, so there is now exactly one. on: workflow_call: - inputs: - release_type: - description: 'Release type (prod or beta)' - required: false - default: 'prod' - type: string + # Manual re-run lever for a stable release whose publish leg failed after the + # tag and GitHub Release were already created. NOTE: npm validates the + # filename of the workflow that STARTED the run, and the registered trusted + # publisher names release-please.yml — so a direct dispatch of this file needs + # its own publisher entry on npmjs.com or it fails with a bare + # `E404 ... PUT /@devicecloud.dev%2fdcd`. workflow_dispatch: - inputs: - release_type: - description: 'Release type (prod or beta)' - required: true - default: 'prod' - type: choice - options: - - prod - - beta jobs: build: @@ -25,14 +26,9 @@ jobs: contents: read # Trusted publishing: npm mints a short-lived token from the OIDC claim # instead of a long-lived NPM_TOKEN (which expired on 2026-09-17). The - # caller must grant this too — release-please.yml's publish-npm-* jobs do - # — because a workflow_call job's permissions are capped by the caller's. - # - # The trusted publisher registered on npmjs.com must name the workflow - # that STARTED the run, not this file: npm validates the calling - # workflow's filename, so the normal release path needs - # `release-please.yml`. A manual workflow_dispatch of this file would need - # `npm-publish.yml` instead. See https://docs.npmjs.com/trusted-publishers + # caller must grant this too — release-please.yml's publish-npm-prod job + # does — because a workflow_call job's permissions are capped by the + # caller's. See https://docs.npmjs.com/trusted-publishers id-token: write steps: - uses: actions/checkout@v7 @@ -63,21 +59,22 @@ jobs: - run: pnpm install --frozen-lockfile - # Prod publishes land on the npm `latest` tag and must only ever come + # 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 whose - # version lacks a -beta suffix as `latest`, bypassing release-please. - # workflow_call is unaffected: release-please.yml only requests a prod - # release on pushes to `production`, so github.ref_name matches there. - - name: Enforce production branch for prod releases - if: ${{ inputs.release_type == 'prod' && github.ref_name != 'production' }} + # 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. + - name: Enforce production branch + if: ${{ github.ref_name != 'production' }} run: | - echo "Error: prod releases may only be published from the 'production' branch (got '${{ github.ref_name }}')" + echo "Error: stable releases may only be published from the 'production' branch (got '${{ github.ref_name }}')" exit 1 - # Version validation for production release + # 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. - name: Validate Production Version - if: ${{ inputs.release_type == 'prod' }} run: | VERSION=$(node -p "require('./package.json').version") if [[ $VERSION =~ -beta ]]; then @@ -86,30 +83,13 @@ jobs: fi echo "Version $VERSION is valid for production release" - # Version validation for beta release - - name: Validate Beta Version - if: ${{ inputs.release_type == 'beta' }} - run: | - VERSION=$(node -p "require('./package.json').version") - if [[ ! $VERSION =~ -beta ]]; then - echo "Error: Beta release must have a beta suffix. Current version: $VERSION" - exit 1 - fi - echo "Version $VERSION is valid for beta release" - # `npm publish`, not `pnpm publish`: pnpm only learned the OIDC exchange # in v11, and this repo pins pnpm 10.17 in packageManager. pnpm still does # the install and the build above; only the upload differs. Safe here # because this is a single package with no workspace: deps -- npm packs # the same `files` list. # - # No NODE_AUTH_TOKEN on either step: its presence would take precedence - # over the OIDC token and put us straight back on the expiring-secret - # path. + # No NODE_AUTH_TOKEN: its presence would take precedence over the OIDC + # token and put us straight back on the expiring-secret path. - name: Publish Production Version - if: ${{ inputs.release_type == 'prod' }} run: npm publish - - - name: Publish Beta Version - if: ${{ inputs.release_type == 'beta' }} - run: npm publish --tag beta diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml new file mode 100644 index 0000000..cb5f7b1 --- /dev/null +++ b/.github/workflows/release-beta.yml @@ -0,0 +1,194 @@ +name: Release beta + +# Manual beta releases. Betas used to be cut automatically by a second +# release-please track on `dev`, with its own config, manifest and changelog. +# That second manifest numbered itself with no knowledge of what stable had +# shipped, so every beta after a stable release sorted BELOW it — `@beta` served +# an older build than `@latest` at 5.0.0, 5.2.0 and 5.5.0, each time needing a +# hand-written `Release-As` commit to escape. There is no manifest now: the +# 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. +on: + workflow_dispatch: + inputs: + version: + description: 'Exact version to publish, e.g. 5.7.0-beta.4. Leave blank to derive the next one.' + required: false + type: string + bump: + description: 'Which part of the published `latest` to bump for the beta base. Ignored when `version` is set.' + required: false + default: minor + type: choice + options: + - patch + - minor + - major + dry_run: + description: 'Resolve the version and build the binaries, but publish nothing.' + required: false + default: false + type: boolean + +permissions: + contents: write + # Trusted publishing: npm mints a short-lived token from the OIDC claim rather + # than a long-lived NPM_TOKEN. + # + # The trusted publisher registered on npmjs.com must name the workflow that + # STARTED the run, so this file needs its own publisher entry — the existing + # one names release-please.yml and will NOT cover this workflow. Without it npm + # rejects the write as `E404 ... PUT /@devicecloud.dev%2fdcd`, which reads like + # a missing package rather than an auth failure. + # See https://docs.npmjs.com/trusted-publishers + id-token: write + +jobs: + release-beta: + 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 + # from it would build from a tree that another process is also writing. + - name: Reject beta releases from production + if: github.ref_name == 'production' + run: | + echo "Error: beta releases may not be cut from 'production'. Dispatch from 'dev' or a feature branch." + exit 1 + + - uses: actions/checkout@v7 + with: + # Full history so the tag-collision check below sees every tag. + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6.1.0 + with: + run_install: false + + # Node 24 for its bundled npm 11: trusted publishing needs npm >= 11.5.1 + # and Node 22 ships npm 10.9. + # + # Deliberately NO `registry-url`. It looks harmless — npmjs.org is the + # default registry anyway — but it makes setup-node write an .npmrc + # containing `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`. With no + # NODE_AUTH_TOKEN in the env (the whole point of trusted publishing) that + # expands to an EMPTY token, npm sees auth as already configured, never + # performs the OIDC exchange, and PUTs unauthenticated. npm answers an + # unauthorised write to an existing package with 404, not 403, so the + # symptom is a bare `E404 ... PUT /@devicecloud.dev%2fdcd` and a log with no + # mention of OIDC at all. That is what broke 5.6.0-beta.1 and beta.2. + # See actions/setup-node#1551 and npm/documentation#1960. + - uses: actions/setup-node@v7 + with: + node-version: '24.x' + cache: 'pnpm' + cache-dependency-path: './pnpm-lock.yaml' + + - run: pnpm install --frozen-lockfile + + - name: Resolve beta version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_BUMP: ${{ inputs.bump }} + run: | + if [ -n "$INPUT_VERSION" ]; then + node scripts/next-beta-version.mjs --version "$INPUT_VERSION" + else + node scripts/next-beta-version.mjs --bump "$INPUT_BUMP" + fi + + # The registry knows what has been PUBLISHED; it does not know what has + # been TAGGED. v5.6.0-beta.1 and v5.6.0-beta.2 are tags whose npm publish + # failed, so a derived version can collide with one — and `gh release + # create` against an existing tag would silently release that tag's old + # commit instead of this one. + - name: Refuse to reuse an existing tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + TAG="v${VERSION}" + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Error: tag ${TAG} already exists (pointing at $(git rev-parse --short "${TAG}"))." + echo "Pass an explicit version, or delete the stale tag if it was never published." + exit 1 + fi + echo "${TAG} is free" + + # Written into the working tree only, never committed. `dev`'s committed + # version tracks the last stable and is maintained by the back-merge that + # release-please.yml opens after each stable release. + - name: Stamp the version into package.json + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + node -e 'const fs=require("node:fs");const p=JSON.parse(fs.readFileSync("package.json","utf8"));p.version=process.env.VERSION;fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' + node -p '"package.json is now " + require("./package.json").version' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3' + + # Built before anything is published so a broken build fails the run + # without leaving a half-release behind. build-binaries.mjs stamps + # __DCD_CLI_VERSION__ from package.json, which is why the step above has to + # run first. + - name: Build binaries for all platforms + run: node scripts/build-binaries.mjs + + # npm BEFORE the GitHub release, deliberately. The old pipeline created the + # tag and release first and published second, so when the npm leg failed it + # left v5.6.0-beta.1 and v5.6.0-beta.2 as releases carrying binaries for + # versions that do not exist on npm. Publishing first means a failed + # publish leaves nothing behind to clean up. + # + # `npm publish`, not `pnpm publish`: pnpm only learned the OIDC exchange in + # v11 and this repo pins pnpm 10.17 in packageManager. No NODE_AUTH_TOKEN on + # this step — its presence would take precedence over the OIDC token. + - name: Publish to npm + if: ${{ !inputs.dry_run }} + run: npm publish --tag beta + + - name: Create the GitHub prerelease + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + gh release create "v${VERSION}" \ + --prerelease \ + --target "${GITHUB_SHA}" \ + --title "v${VERSION}" \ + --generate-notes + + - name: Upload binaries to the prerelease + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + # Glob rather than a hand-listed set: build-binaries.mjs rm -rf's + # dist-bin and then writes exactly the five binaries plus SHA256SUMS, so + # the directory IS the asset list. Naming them here as well would mean a + # new target had to be added in three places instead of one. + run: gh release upload "v${VERSION}" dist-bin/* --clobber + + - name: Summary + env: + VERSION: ${{ steps.version.outputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + { + echo "### Beta ${VERSION}" + echo + if [ "${DRY_RUN}" = "true" ]; then + echo "**Dry run** — nothing was published or tagged." + else + echo "- npm: \`npx @devicecloud.dev/dcd@${VERSION}\` (dist-tag \`beta\`)" + echo "- release: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/v${VERSION}" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 16bb21b..f8cbd7a 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -57,15 +57,11 @@ jobs: echo "name=v${VERSION}" >> "$GITHUB_OUTPUT" fi + # Glob rather than a hand-listed set: build-binaries.mjs rm -rf's dist-bin + # and then writes exactly the five binaries plus SHA256SUMS, so the + # directory IS the asset list. release-beta.yml uploads the same way. - name: Upload binaries to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${{ steps.tag.outputs.name }}" \ - dist-bin/dcd-darwin-arm64 \ - dist-bin/dcd-darwin-x64 \ - dist-bin/dcd-linux-arm64 \ - dist-bin/dcd-linux-x64 \ - dist-bin/dcd-windows-x64.exe \ - dist-bin/SHA256SUMS \ - --clobber + TAG: ${{ steps.tag.outputs.name }} + run: gh release upload "$TAG" dist-bin/* --clobber diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 45f4d84..48e012c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,17 +1,19 @@ name: Release -# Drives the full release pipeline: -# - Push to `production` → stable release (no -beta suffix, npm `latest` tag). -# - Push to `dev` → beta release (e.g. 5.1.0-beta.0, npm `beta` tag, GitHub -# "pre-release" badge). -# In both cases release-please opens/updates 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`.) +# 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`.) +# +# 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. on: push: - branches: [production, dev] + branches: [production] workflow_dispatch: permissions: @@ -49,32 +51,6 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - release-please-beta: - if: github.ref_name == 'dev' - runs-on: ubuntu-latest - # See release-please-prod above for why this uses an App token with a - # GITHUB_TOKEN fallback. - env: - BOT_APP_ID: ${{ secrets.BOT_APP_ID }} - 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 != '' - with: - app-id: ${{ secrets.BOT_APP_ID }} - private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - uses: googleapis/release-please-action@v5 - id: release - with: - token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} - target-branch: dev - config-file: release-please-config-beta.json - manifest-file: .release-please-manifest-beta.json - publish-npm-prod: # Must be granted here too: a reusable workflow can never hold a # permission its caller does not, and this workflow's top-level @@ -86,8 +62,6 @@ jobs: needs: release-please-prod if: needs.release-please-prod.outputs.release_created == 'true' uses: ./.github/workflows/npm-publish.yml - with: - release_type: prod secrets: inherit publish-binaries-prod: @@ -98,25 +72,67 @@ jobs: tag_name: ${{ needs.release-please-prod.outputs.tag_name }} secrets: inherit - publish-npm-beta: - # Must be granted here too: a reusable workflow can never hold a - # permission its caller does not, and this workflow's top-level - # block has none. Without it npm-publish's own id-token: write is - # silently dropped and trusted publishing falls back to a token. + # 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. + back-merge: + needs: release-please-prod + if: needs.release-please-prod.outputs.release_created == 'true' + runs-on: ubuntu-latest permissions: - contents: read - id-token: write - needs: release-please-beta - if: needs.release-please-beta.outputs.release_created == 'true' - uses: ./.github/workflows/npm-publish.yml - with: - release_type: beta - secrets: inherit + contents: write + pull-requests: write + 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. + - uses: actions/create-github-app-token@v3 + id: app-token + if: env.BOT_APP_ID != '' + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - publish-binaries-beta: - needs: release-please-beta - if: needs.release-please-beta.outputs.release_created == 'true' - uses: ./.github/workflows/release-binaries.yml - with: - tag_name: ${{ needs.release-please-beta.outputs.tag_name }} - secrets: inherit + - uses: actions/checkout@v7 + with: + ref: dev + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Open the back-merge PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.release-please-prod.outputs.version }} + run: | + set -euo pipefail + BRANCH="chore/back-merge-${VERSION}" + + git config user.name "github-actions[bot]" + 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. + 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 + exit 1 + fi + + if git diff --quiet "origin/dev..HEAD"; then + echo "dev already contains production; nothing to back-merge." + exit 0 + fi + + git push origin "$BRANCH" + gh pr create \ + --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." diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json deleted file mode 100644 index 9330407..0000000 --- a/.release-please-manifest-beta.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "5.6.0-beta.2" -} diff --git a/CHANGELOG-beta.md b/CHANGELOG-beta.md index c6de11d..b9183ad 100644 --- a/CHANGELOG-beta.md +++ b/CHANGELOG-beta.md @@ -1,5 +1,11 @@ # Changelog +> **Frozen.** Betas are no longer cut by release-please, so nothing appends to +> this file any more. They are published on demand from the `Release beta` +> workflow, and each one's notes live on its GitHub prerelease. +> Entries below are kept as the historical record of the beta line up to +> 5.6.0-beta.2. + ## [5.6.0-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.6.0-beta.1...v5.6.0-beta.2) (2026-09-18) diff --git a/CLAUDE.md b/CLAUDE.md index d1a558c..0bf72db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,23 +59,37 @@ Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones tha - 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. - 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` / `CHANGELOG-beta.md`, or the `.release-please-manifest*.json` files** — release-please owns all of them. `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). +- **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 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 -Fully automated by [release-please](https://github.com/googleapis/release-please) — no manual version bumping. `.github/workflows/release-please.yml` drives **two parallel tracks off two separate config+manifest pairs**: +**Stable releases are automatic; betas are cut by hand.** -| Push to | Track | Config / manifest | Version | npm tag | -| --- | --- | --- | --- | --- | -| `dev` | **beta** (prerelease) | `release-please-config-beta.json` / `.release-please-manifest-beta.json` | `X.Y.Z-beta.N` | `beta` | -| `production` | **stable** | `release-please-config.json` / `.release-please-manifest.json` | `X.Y.Z` | `latest` | +| Line | Trigger | 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` | -The two tracks also keep **separate changelog files** — beta writes `CHANGELOG-beta.md`, stable writes `CHANGELOG.md` — so a promotion never conflicts on them. The two manifests track their versions **independently** (e.g. beta `5.0.0-beta.3` while stable is `5.0.0`). On each qualifying push release-please opens/updates a **Release PR** on that branch; merging the Release PR creates the git tag + GitHub Release, and the same workflow run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into: -1. `npm-publish.yml` — publishes to npm. Guards: a prod publish may only run from `production` and its version must **not** carry `-beta`; a beta version **must** carry `-beta`. -2. `release-binaries.yml` — bun-compiles the standalone binaries (`node scripts/build-binaries.mjs`) and uploads them to the GitHub Release. `get.devicecloud.dev` serves them by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. +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. -**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. +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. -The only conflict a promotion should raise is the `version` line in `package.json` (beta on one side, stable on the other). **Take `dev`'s** — release-please overwrites it from `.release-please-manifest.json` when the Release PR lands. Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR triggers the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured. +**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. + +### Every promotion must carry a `Release-As:` pin + +Put the stable version on its own commit in the promotion branch: + +``` +git commit --allow-empty -m "chore: pin the X.Y.Z promotion" -m "Release-As: X.Y.Z" +``` + +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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78f47ab..562769f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,8 +113,9 @@ Allowed types and how they affect the next release: | `style` | Formatting, whitespace | hidden | none | **Breaking changes:** append `!` after the type (e.g. `feat!: drop Node 20`) or -add a `BREAKING CHANGE:` footer in the PR description. While the CLI is pre-1.0, -`feat` bumps the minor version and breaking changes bump the minor too. +add a `BREAKING CHANGE:` footer in the PR description. The CLI is on 5.x, so this +bumps the **major** version — please don't reach for it casually. (The configs set +`bump-minor-pre-major`, but that only applies below 1.0.0 and is inert here.) Examples: @@ -139,17 +140,20 @@ 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. -Releases are automated by [release-please](https://github.com/googleapis/release-please): - -- Merges to `dev` accumulate into a **beta** release (published to npm under the - `beta` tag). -- Maintainers promote `dev` → `production` for **stable** releases (npm `latest`), - always with a **merge commit** so the two branches stay in sync. +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. +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 +rather than automatically on merge. + ## Questions - General questions and help: [Discord](https://discord.gg/gm3mJwcNw8). diff --git a/release-please-config-beta.json b/release-please-config-beta.json deleted file mode 100644 index 2dfdfed..0000000 --- a/release-please-config-beta.json +++ /dev/null @@ -1,32 +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": true, - "prerelease-type": "beta", - "versioning": "prerelease", - "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-beta.md" - } - } -} diff --git a/scripts/next-beta-version.mjs b/scripts/next-beta-version.mjs new file mode 100644 index 0000000..9d4a100 --- /dev/null +++ b/scripts/next-beta-version.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Resolve the version for a manual beta release (.github/workflows/release-beta.yml). + * + * The beta line has no release-please manifest any more, so the npm registry is + * the only state: whatever is published IS the source of truth. That is + * deliberate. A manifest that numbered itself independently of what had actually + * shipped is exactly how `@beta` ended up *older* than `@latest` three times + * (5.0.0, 5.2.0 and 5.5.0) — each time needing a hand-written `Release-As` to + * dig out. Deriving from the registry makes that impossible by construction: + * the base is always a bump of the current `latest`, so a beta can never sort + * below the stable it is meant to preview. + * + * Usage: + * node scripts/next-beta-version.mjs [--bump patch|minor|major] [--version X.Y.Z-beta.N] + * + * Prints the resolved version to stdout, and appends `version=` to + * $GITHUB_OUTPUT when that is set. + */ +const PACKAGE = '@devicecloud.dev/dcd'; +const REGISTRY = 'https://registry.npmjs.org/@devicecloud.dev%2Fdcd'; + +// Deliberately NOT a general semver implementation — this package only ever +// publishes `X.Y.Z` and `X.Y.Z-beta.N`, and anything else reaching here means a +// wrong assumption somewhere upstream that should fail loudly rather than be +// silently coerced. +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; +const BETA = /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/; + +function parse(version) { + const stable = STABLE.exec(version); + if (stable) { + const [, major, minor, patch] = stable; + return { major: +major, minor: +minor, patch: +patch, beta: null }; + } + const beta = BETA.exec(version); + if (beta) { + const [, major, minor, patch, n] = beta; + return { major: +major, minor: +minor, patch: +patch, beta: +n }; + } + return null; +} + +/** -1 / 0 / 1. A prerelease sorts below the release it precedes. */ +function compare(a, b) { + for (const part of ['major', 'minor', 'patch']) { + if (a[part] !== b[part]) return a[part] < b[part] ? -1 : 1; + } + if (a.beta === b.beta) return 0; + if (a.beta === null) return 1; + if (b.beta === null) return -1; + return a.beta < b.beta ? -1 : 1; +} + +function bumpBase({ major, minor, patch }, kind) { + if (kind === 'major') return `${major + 1}.0.0`; + if (kind === 'minor') return `${major}.${minor + 1}.0`; + if (kind === 'patch') return `${major}.${minor}.${patch + 1}`; + throw new Error(`Unknown bump "${kind}" (expected patch, minor or major)`); +} + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return ''; + return (process.argv[i + 1] ?? '').trim(); +} + +const requested = arg('version'); +const bump = arg('bump') || 'minor'; + +// The registry, not `npm view` — npm's CLI serves stale cached metadata for +// minutes after a publish, which would hand back a version number that is +// already taken. +const response = await fetch(REGISTRY, { + headers: { accept: 'application/vnd.npm.install-v1+json' }, +}); +if (!response.ok) { + fail(`registry returned ${response.status} ${response.statusText} for ${PACKAGE}`); +} +const packument = await response.json(); + +const latestRaw = packument['dist-tags']?.latest; +if (!latestRaw) fail(`${PACKAGE} has no "latest" dist-tag`); +const latest = parse(latestRaw); +if (!latest) fail(`could not parse the published latest version "${latestRaw}"`); +if (latest.beta !== null) { + fail(`the "latest" dist-tag points at a prerelease (${latestRaw}); fix that before cutting a beta`); +} + +const published = new Set(Object.keys(packument.versions ?? {})); + +let version; +if (requested) { + const parsed = parse(requested); + if (!parsed) fail(`"${requested}" is not a valid X.Y.Z-beta.N version`); + if (parsed.beta === null) fail(`"${requested}" is not a beta version`); + if (compare(parsed, latest) <= 0) { + fail(`"${requested}" is not above the published latest (${latestRaw}); @beta must never be older than @latest`); + } + version = requested; +} else { + const base = bumpBase(latest, bump); + const baseParsed = parse(base); + let highest = 0; + for (const candidate of published) { + const parsed = parse(candidate); + if (!parsed || parsed.beta === null) continue; + if (compare({ ...parsed, beta: null }, baseParsed) !== 0) continue; + if (parsed.beta > highest) highest = parsed.beta; + } + version = `${base}-beta.${highest + 1}`; +} + +if (published.has(version)) { + fail(`${version} is already published — npm versions can never be reused`); +} + +console.log(version); +if (process.env.GITHUB_OUTPUT) { + const { appendFileSync } = await import('node:fs'); + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); +}