Skip to content
Closed
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
96 changes: 44 additions & 52 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
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:
runs-on: ubuntu-latest
permissions:
contents: read
# Trusted publishing: npm is configured with this repo + this workflow as
# the publisher for @devicecloud.dev/dcd, and mints a short-lived token
# from the OIDC claim instead of a long-lived NPM_TOKEN. Without this
# permission the runner cannot request the claim at all, and npm falls
# back to the token -- which is what expired on 2026-09-17 and failed the
# publish with a 404 on PUT.
# 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-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
# Setup .npmrc file to publish to npm
- name: Setup pnpm
uses: pnpm/action-setup@v6.1.0
with:
Expand All @@ -42,30 +41,40 @@ jobs:
# and Node 22 ships npm 10.9. This is the publish job only -- what the CLI
# itself supports at runtime is set by tsconfig, not by the Node that
# builds it.
# 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'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'

- 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 @@ -74,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
Loading
Loading