diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index feed479..8e1eef7 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -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 @@ -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' @@ -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 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 3913334..45f4d84 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -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 @@ -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 diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 52d1780..a52ae01 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "5.5.0-beta.4" + ".": "5.6.0-beta.1" } diff --git a/CHANGELOG-beta.md b/CHANGELOG-beta.md index 311eab8..4161d22 100644 --- a/CHANGELOG-beta.md +++ b/CHANGELOG-beta.md @@ -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) diff --git a/package.json b/package.json index a8a1df6..d4b42bf 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" }, diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 3b8320d..a7173a3 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -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 @@ -819,6 +820,7 @@ export const cloudCommand = defineCommand({ androidNoSnapshot, apiUrl, appBinaryId: finalBinaryId, + cancelPrevious, cliVersion, commonRoot, continueOnFailure, diff --git a/src/config/flags/execution.flags.ts b/src/config/flags/execution.flags.ts index e02dabb..3e4988b 100644 --- a/src/config/flags/execution.flags.ts +++ b/src/config/flags/execution.flags.ts @@ -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: diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 774fc66..5b26839 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -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; @@ -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 @@ -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, @@ -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`); diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 61cda81..4a5fe78 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -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; @@ -86,6 +93,7 @@ export class TestSubmissionService { cliVersion, env = [], metadata = [], + cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, @@ -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, diff --git a/test/unit/superseded-run.test.ts b/test/unit/superseded-run.test.ts new file mode 100644 index 0000000..c2b72e0 --- /dev/null +++ b/test/unit/superseded-run.test.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai'; + +import { + isSupersededRow, + ResultsPollingService, + supersedingConsoleUrl, + supersedingUploadId, +} from '../../src/services/results-polling.service.js'; + +// A superseded run is one `dcd cloud --cancel-previous` replaced: a newer run +// of the same CI context cancelled its queued tests. The verdict below is what +// decides whether that older CI job exits 0 or fails the build, so it is worth +// pinning down separately from the polling loop around it. +const row = (overrides: Record = {}) => ({ + id: 1, + test_file_name: 'flow.yaml', + status: 'PASSED', + retry_of: null, + created_at: '2026-09-17T10:00:00.000Z', + duration_seconds: 1, + fail_reason: null, + simulator_name: 'pixel-7', + ...overrides, +}); + +const cancelledBySupersede = (id: number) => + row({ + id, + status: 'CANCELLED', + cancellation_reason: 'superseded_by:newer-upload', + }); + +// buildPollingResult is private; it is the whole point of this file, so reach +// it rather than re-implementing the verdict in the test. +const verdict = (results: unknown[]) => + ( + new ResultsPollingService() as unknown as { + buildPollingResult: ( + r: unknown[], + uploadId: string, + consoleUrl: string, + ) => { status: string }; + } + ).buildPollingResult(results, 'upload-1', 'https://console/upload-1').status; + +describe('superseded runs', () => { + describe('isSupersededRow', () => { + it('matches only the superseded token', () => { + expect(isSupersededRow(cancelledBySupersede(1))).to.equal(true); + expect(isSupersededRow(row({ status: 'CANCELLED' }))).to.equal(false); + expect( + isSupersededRow(row({ cancellation_reason: 'user' })), + ).to.equal(false); + expect(isSupersededRow(row())).to.equal(false); + expect(isSupersededRow(null)).to.equal(false); + }); + }); + + describe('supersedingUploadId', () => { + it('reads the newer upload id out of the reason', () => { + expect(supersedingUploadId([row(), cancelledBySupersede(2)])).to.equal( + 'newer-upload', + ); + }); + + it('is undefined when nothing was superseded', () => { + expect(supersedingUploadId([row()])).to.equal(undefined); + }); + }); + + describe('supersedingConsoleUrl', () => { + const base = 'https://dev.console.devicecloud.dev/results?upload=A&result=44376'; + + it('points at the newer upload and drops the old result id', () => { + // 44376 is a result of upload A; carried over it would deep-link B to a + // test that is not in it. + expect(supersedingConsoleUrl(base, 'A', 'B')).to.equal( + 'https://dev.console.devicecloud.dev/results?upload=B', + ); + }); + + it('handles a url with no result param', () => { + expect( + supersedingConsoleUrl('https://c/results?upload=A', 'A', 'B'), + ).to.equal('https://c/results?upload=B'); + }); + + it('handles result appearing first', () => { + expect( + supersedingConsoleUrl('https://c/results?result=1&upload=A', 'A', 'B'), + ).to.equal('https://c/results?upload=B'); + }); + }); + + describe('the run verdict', () => { + it('is PASSED when every test passed', () => { + expect(verdict([row(), row({ id: 2 })])).to.equal('PASSED'); + }); + + it('is FAILED for an ordinary cancel, as before', () => { + // No reason on the row: someone cancelled this run by hand, and the + // build should still go red. + expect(verdict([row(), row({ id: 2, status: 'CANCELLED' })])).to.equal( + 'FAILED', + ); + }); + + it('is SUPERSEDED when a newer run replaced this one', () => { + expect(verdict([row(), cancelledBySupersede(2)])).to.equal('SUPERSEDED'); + }); + + it('is SUPERSEDED even when a test had already genuinely failed', () => { + // This run no longer speaks for the commit — the newer one does — so it + // must not fail the build. The failure is still reported in tests[]. + const results = [ + row({ id: 1, status: 'FAILED', fail_reason: 'assertion failed' }), + cancelledBySupersede(2), + ]; + + expect(verdict(results)).to.equal('SUPERSEDED'); + }); + + it('is unchanged when every test finished before the newer run arrived', () => { + // Nothing was still queued, so nothing was cancelled and no marker was + // written: the verdict is whatever it would have been. + expect( + verdict([row(), row({ id: 2, status: 'FAILED' })]), + ).to.equal('FAILED'); + }); + }); +});