Skip to content
Closed
30 changes: 23 additions & 7 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ jobs:
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.
id-token: write
steps:
- uses: actions/checkout@v7
# Setup .npmrc file to publish to npm
Expand All @@ -31,9 +38,13 @@ jobs:
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. This is the publish job only -- what the CLI
# itself supports at runtime is set by tsconfig, not by the Node that
# builds it.
- uses: actions/setup-node@v7
with:
node-version: '22.x'
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'
Expand Down Expand Up @@ -74,14 +85,19 @@ jobs:
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.
- name: Publish Production Version
if: ${{ inputs.release_type == 'prod' }}
run: pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish

- name: Publish Beta Version
if: ${{ inputs.release_type == 'beta' }}
run: pnpm publish --tag beta --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --tag beta
14 changes: 14 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ jobs:
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
# block has none. Without it npm-publish's own id-token: write is
# silently dropped and trusted publishing falls back to a token.
permissions:
contents: read
id-token: write
needs: release-please-prod
if: needs.release-please-prod.outputs.release_created == 'true'
uses: ./.github/workflows/npm-publish.yml
Expand All @@ -92,6 +99,13 @@ jobs:
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.
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
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest-beta.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "5.5.0-beta.4"
".": "5.6.0-beta.1"
}
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "5.0.0"
".": "5.5.0"
}
19 changes: 19 additions & 0 deletions CHANGELOG-beta.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [5.6.0-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.5.0-beta.5...v5.6.0-beta.1) (2026-09-17)


### Bug Fixes

* **cloud:** drop the stale result id from the superseded-by link ([#166](https://github.com/devicecloud-dev/dcd-cli/issues/166)) ([a781e4c](https://github.com/devicecloud-dev/dcd-cli/commit/a781e4c87e96100e83309b5b68785aa1a8ca4e15))


### Miscellaneous

* re-anchor the beta line and pin the next beta to 5.6.0-beta.1 ([#170](https://github.com/devicecloud-dev/dcd-cli/issues/170)) ([d089531](https://github.com/devicecloud-dev/dcd-cli/commit/d089531a50c0c239ce95c136f403b06892afe638))

## [5.5.0-beta.5](https://github.com/devicecloud-dev/dcd-cli/compare/v5.5.0-beta.4...v5.5.0-beta.5) (2026-09-17)


### Features

* **cloud:** add --cancel-previous to supersede the previous CI run ([#164](https://github.com/devicecloud-dev/dcd-cli/issues/164)) ([169036d](https://github.com/devicecloud-dev/dcd-cli/commit/169036deef348b5955ca2c2a70169c876829eca3))

## [5.5.0-beta.4](https://github.com/devicecloud-dev/dcd-cli/compare/v5.5.0-beta.3...v5.5.0-beta.4) (2026-09-14)


Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## [5.5.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.4.1...v5.5.0) (2026-09-15)


### Features

* add Pixel 8, Pixel 10 family and Pixel 11 device slugs ([#151](https://github.com/devicecloud-dev/dcd-cli/issues/151)) ([dac5d1e](https://github.com/devicecloud-dev/dcd-cli/commit/dac5d1ec88c3488802e244338684cfc0f1e2d50f))
* **cloud:** add --render-engine for the emulator software renderer ([#153](https://github.com/devicecloud-dev/dcd-cli/issues/153)) ([a2888fd](https://github.com/devicecloud-dev/dcd-cli/commit/a2888fd0154ce637a3a7fd02dc04431114df99e0))


### Bug Fixes

* register tsx's ESM loader only so mocha 12.0.1 can load find-up@8 ([#157](https://github.com/devicecloud-dev/dcd-cli/issues/157)) ([004b478](https://github.com/devicecloud-dev/dcd-cli/commit/004b47838983a6e1c6caca060e7c43f85a737f4a))


### Dependencies

* bump the minor-and-patch group with 7 updates ([#156](https://github.com/devicecloud-dev/dcd-cli/issues/156)) ([1e6e2c0](https://github.com/devicecloud-dev/dcd-cli/commit/1e6e2c087817218d9fc8da69a95089253b779d39))


### Miscellaneous

* pin the 5.5.0 promotion ([ea27543](https://github.com/devicecloud-dev/dcd-cli/commit/ea275430ad5e8369a83a07b51fd9c1ec0addd3d1))

## [5.4.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.4.0...v5.4.1) (2026-09-10)


Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
},
"repository": {
"type": "git",
"url": "https://devicecloud.dev"
"url": "git+https://github.com/devicecloud-dev/dcd-cli.git"
},
"scripts": {
"dcd": "tsx src/index.ts",
Expand All @@ -69,7 +69,7 @@
"test:unit": "node scripts/test-runner.mjs --unit",
"typecheck": "tsc --noEmit -p tsconfig.test.json"
},
"version": "5.5.0-beta.4",
"version": "5.6.0-beta.1",
"bugs": {
"url": "https://discord.gg/gm3mJwcNw8"
},
Expand Down
2 changes: 2 additions & 0 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export const cloudCommand = defineCommand({
collectRepeatedFlag(rawArgs, ['--exclude-tags']),
);
let flows = args.flows as string | undefined;
const cancelPrevious = Boolean(args['cancel-previous']);
const googlePlay = Boolean(args['google-play']);
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
// Single opt-in for client-side envelope encryption of every sensitive
Expand Down Expand Up @@ -819,6 +820,7 @@ export const cloudCommand = defineCommand({
androidNoSnapshot,
apiUrl,
appBinaryId: finalBinaryId,
cancelPrevious,
cliVersion,
commonRoot,
continueOnFailure,
Expand Down
6 changes: 6 additions & 0 deletions src/config/flags/execution.flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import type { ArgsDef } from 'citty';
* Test execution and flow management flags
*/
export const executionFlags = {
'cancel-previous': {
type: 'boolean',
default: false,
description:
'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.',
},
config: {
type: 'string',
description:
Expand Down
104 changes: 99 additions & 5 deletions src/services/results-polling.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,55 @@ export function deviceFromResultRow(r: {
};
}

/**
* Was this row cancelled because a newer run from the same CI context
* replaced it (`dcd cloud --cancel-previous`)?
*
* Read through a structural cast for the same reason deviceFromResultRow
* does: the committed generated types are regenerated wholesale from dev's
* swagger and lag the API, and this must work against an API that already
* sends the field.
*/
export function isSupersededRow(r: unknown): boolean {
const reason = (r as { cancellation_reason?: string | null } | null)
?.cancellation_reason;
return typeof reason === 'string' && reason.startsWith('superseded_by:');
}

/** The upload that superseded this run, for the console link. */
export function supersedingUploadId(results: unknown[]): string | undefined {
for (const r of results) {
const reason = (r as { cancellation_reason?: string | null } | null)
?.cancellation_reason;
if (typeof reason === 'string' && reason.startsWith('superseded_by:')) {
return reason.slice('superseded_by:'.length) || undefined;
}
}
return undefined;
}

/**
* The superseding run's console link, derived from this run's.
*
* The `result` parameter deep-links a single test, and that id belongs to
* THIS upload — carrying it over would point at a result the newer upload
* does not contain. Swap the upload id and drop it.
*/
export function supersedingConsoleUrl(
consoleUrl: string,
uploadId: string,
supersededBy: string,
): string {
return consoleUrl
.replace(uploadId, supersededBy)
.replace(/&result=[^&]*/, '')
.replace(/\?result=[^&]*&/, '?')
.replace(/\?result=[^&]*$/, '');
}

export interface PollingResult {
consoleUrl: string;
status: 'FAILED' | 'PASSED';
status: 'FAILED' | 'PASSED' | 'SUPERSEDED';
tests: Array<{
/** Device this result ran on (present when the API reports it). */
device?: TestDevice;
Expand Down Expand Up @@ -339,13 +385,24 @@ export class ResultsPollingService {
): PollingResult {
const resultsWithoutEarlierTries = this.filterLatestResults(results);

// ANY superseded row settles the whole verdict, even alongside a test
// that had genuinely failed before the newer run replaced this one: this
// run no longer speaks for the commit, so failing the build on its behalf
// is wrong. Actions reaches the same conclusion — a cancelled run's
// conclusion is `cancelled`, whatever had already failed inside it. The
// failure is still printed and still in `tests[]`; only the exit code
// changes.
const superseded = resultsWithoutEarlierTries.some(isSupersededRow);

return {
consoleUrl,
// Anything other than an explicit pass (CANCELLED, ERROR, a status we
// don't know about yet) must fail the run — this gates CI exit codes.
status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
? 'PASSED'
: 'FAILED',
status: superseded
? 'SUPERSEDED'
: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
? 'PASSED'
: 'FAILED',
tests: resultsWithoutEarlierTries.map((r) => ({
// r carries config/simulator_name at runtime; the committed generated
// types lag the API (regenerated wholesale from dev's swagger), so read
Expand Down Expand Up @@ -386,8 +443,12 @@ export class ResultsPollingService {
const pending = statusCounts.PENDING || 0;
const queued = statusCounts.QUEUED || 0;
const running = statusCounts.RUNNING || 0;
// CANCELLED is terminal, so it counts as completed. Without it a
// cancelled or superseded run's footer sticks at "8/12 completed"
// forever, having already stopped polling.
const cancelled = statusCounts.CANCELLED || 0;
const total = results.length;
const completed = passed + failed;
const completed = passed + failed + cancelled;

const summary = formatTestSummary({
completed,
Expand Down Expand Up @@ -562,6 +623,39 @@ export class ResultsPollingService {
testMetadata,
);

if (output.status === 'SUPERSEDED') {
// Exit 0: a newer run of the same CI context replaced this one, so
// failing the build here would fail it for work nobody is waiting on.
// Falls through to the success return below — RunFailedError, and with
// it the exit code 2 in `dcd cloud`, is never reached.
if (logger && !json) {
const newer = supersedingUploadId(updatedResults);
logger('\n');
logger(
ui.warn(
'Run superseded by a newer run from the same CI context — exiting 0',
),
);
if (newer) {
logger(
ui.branch(
ui.fields([
[
'superseded by',
colors.url(
supersedingConsoleUrl(consoleUrl, uploadId, newer),
),
],
]),
),
);
}
logger('\n');
}

return output;
}

if (output.status === 'FAILED') {
if (debug && logger) {
logger(`[DEBUG] Some tests failed, returning failed status`);
Expand Down
14 changes: 14 additions & 0 deletions src/services/test-submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export interface TestSubmissionConfig {
androidNoSnapshot?: boolean;
apiUrl?: string;
appBinaryId: string;
/**
* Ask the API to cancel the still-queued tests of the previous run from
* the same CI context. Sent as its own field rather than inside `config`,
* which is stamped onto every result row and shipped to the runner — this
* is a one-off submission directive, not run configuration.
*/
cancelPrevious?: boolean;
cliVersion: string;
commonRoot: string;
continueOnFailure?: boolean;
Expand Down Expand Up @@ -86,6 +93,7 @@ export class TestSubmissionService {
cliVersion,
env = [],
metadata = [],
cancelPrevious = false,
googlePlay = false,
androidApiLevel,
androidDevice,
Expand Down Expand Up @@ -308,6 +316,12 @@ export class TestSubmissionService {
);
}

// Only sent when asked for, so every other submission's wire shape is
// unchanged and the flag is simply ignored by an older API.
if (cancelPrevious) {
fields.cancelPrevious = 'true';
}

this.setOptionalFields(fields, {
androidApiLevel,
androidDevice,
Expand Down
Loading
Loading