Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
84 changes: 32 additions & 52 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
194 changes: 194 additions & 0 deletions .github/workflows/release-beta.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading