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
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"
}
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
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",
"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