From aaf91a1ee5688bfbd261f3360d5ec4ca8e5ceb0d Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:48:57 +0100 Subject: [PATCH 1/6] feat(config): add includedPaths to ship non-flow files (#178) Only addMedia/runFlow/runScript arguments were bundled, so assertScreenshot baselines never reached the device. `includedPaths` globs extra files into the upload; assertScreenshot paths are now also picked up automatically (optional, so a missing baseline doesn't abort the run). Co-authored-by: Claude Opus 5 (1M context) --- src/commands/cloud.ts | 20 +- src/mcp/tools/run-cloud-test.ts | 1 + src/services/execution-plan.service.ts | 141 +++++++++++++- src/services/execution-plan.utils.ts | 76 +++++++- src/services/flow-paths.ts | 17 +- src/services/test-submission.service.ts | 18 ++ src/services/workspace-config.schema.ts | 6 + test/unit/included-paths.test.ts | 249 ++++++++++++++++++++++++ 8 files changed, 518 insertions(+), 10 deletions(-) create mode 100644 test/unit/included-paths.test.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index a7173a3..deac9d9 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -621,6 +621,7 @@ export const cloudCommand = defineCommand({ flowMetadata, flowOverrides, flowsToRun: testFileNames, + includedFiles, referencedFiles, sequence, } = executionPlan; @@ -635,10 +636,27 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`); } - const commonRoot = computeCommonRoot(testFileNames, referencedFiles); + const commonRoot = computeCommonRoot( + testFileNames, + referencedFiles, + includedFiles, + ); if (debug) { out(`[DEBUG] Common root directory: ${commonRoot}`); + + // `includedPaths` files sitting beside the flows tree rather than + // inside it raise the common root, so every server-side flow key gains + // a leading segment. Harmless but visible in the console, so say it. + const rootWithoutIncludes = computeCommonRoot( + testFileNames, + referencedFiles, + ); + if (includedFiles.length > 0 && rootWithoutIncludes !== commonRoot) { + out( + `[DEBUG] \`includedPaths\` raised the common root from ${rootWithoutIncludes} to ${commonRoot} — flow paths gain a leading segment`, + ); + } } const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot); diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 5c94dca..a94d07e 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -158,6 +158,7 @@ export function registerRunCloudTest(server: McpServer): void { const commonRoot = computeCommonRoot( executionPlan.flowsToRun, executionPlan.referencedFiles, + executionPlan.includedFiles, ); const testMetadataMap = buildTestMetadataMap( executionPlan.flowMetadata, diff --git a/src/services/execution-plan.service.ts b/src/services/execution-plan.service.ts index 9c1284c..994c378 100644 --- a/src/services/execution-plan.service.ts +++ b/src/services/execution-plan.service.ts @@ -35,6 +35,12 @@ export interface IExecutionPlan { flowMetadata: Record>; flowOverrides: Record>; flowsToRun: string[]; + /** + * Extra files pulled in by `config.yaml`'s `includedPaths`. Kept separate + * from `referencedFiles` (which is derived from flow commands) so the zip + * manifest and the common-root calculation can tell the two apart. + */ + includedFiles: string[]; referencedFiles: string[]; sequence?: IFlowSequence | null; totalFlowFiles: number; @@ -178,6 +184,7 @@ async function planSingleFile( normalizedInput: string, warn: (message: string) => void, resolvedConfigFile?: string, + debug = false, ): Promise { const inputBasename = path.basename(normalizedInput); if ( @@ -218,11 +225,24 @@ async function planSingleFile( } } + // A single-file input has no workspace directory, so `includedPaths` (which + // only reaches here via --config) anchors on the flow file's own directory — + // the same place Maestro resolves an assertScreenshot baseline from. + const includedFiles = workspaceConfig + ? resolveIncludedPaths( + workspaceConfig, + path.dirname(normalizedInput), + warn, + debug, + ) + : []; + const checkedDependancies = await checkDependencies(normalizedInput); return { flowMetadata, flowOverrides, flowsToRun: [normalizedInput], + includedFiles, referencedFiles: [...new Set(checkedDependancies)], totalFlowFiles: 1, workspaceConfig, @@ -288,6 +308,119 @@ async function applyFlowGlobs( return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file)); } +/** + * The whole archive is buffered in memory by `compressFilesFromRelativePath`, + * so an unbounded `**` glob is a real footgun. These are warn thresholds, not + * hard limits — a legitimately large baseline set should still upload. + */ +const INCLUDED_PATHS_FILE_WARN_THRESHOLD = 200; +const INCLUDED_PATHS_BYTES_WARN_THRESHOLD = 50 * 1024 * 1024; + +/** + * Resolve `config.yaml`'s `includedPaths` globs into absolute file paths. + * + * This is the general-purpose escape hatch for shipping files the flow + * commands don't reference: `assertScreenshot` baselines above all, but also + * fixtures, test data and certificates. Only `addMedia` / `runFlow` / + * `runScript` arguments are discovered by walking the flows, so without this + * key such files are silently absent from the uploaded zip. + * + * Glob semantics deliberately mirror `flows:` (`applyFlowGlobs`): patterns + * resolve against the workspace root, never the config file's directory, so + * `--config ci/workspace.yaml` behaves identically to an auto-detected config. + * + * @param workspaceConfig - Validated workspace config + * @param normalizedInput - Normalized path to the workspace directory + * @param warn - Sink for non-fatal problems + * @param debug - Whether to emit debug logging + * @returns Absolute paths of every matched file, deduped and sorted + * @throws Error if a pattern escapes the workspace root + */ +function resolveIncludedPaths( + workspaceConfig: IWorkspaceConfig, + normalizedInput: string, + warn: (message: string) => void, + debug = false, +): string[] { + const patterns = workspaceConfig.includedPaths; + if (!patterns || patterns.length === 0) return []; + + const workspaceRoot = path.resolve(normalizedInput); + const resolved = new Set(); + const unmatched: string[] = []; + + for (const pattern of patterns) { + // fs.globSync lands in Node 22; the CLI's `engines.node` already requires + // it. No `nodir` option — directories are stripped by the stat check below. + const matches = fs.globSync(pattern, { cwd: normalizedInput }); + let matchedFile = false; + + for (const match of matches) { + const absolute = path.resolve(normalizedInput, match); + + // Containment guard: `flows:` has none because its matches are only ever + // parsed as YAML, but this key ships arbitrary bytes to a remote runner, + // so a `../../../` climb must not silently leave the workspace. + const relative = path.relative(workspaceRoot, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error( + `\`includedPaths\` pattern "${pattern}" resolves outside the workspace: ${absolute}\n\n` + + `Included paths must stay within ${workspaceRoot}.`, + ); + } + + try { + if (!fs.statSync(absolute).isFile()) continue; + } catch { + continue; + } + + matchedFile = true; + resolved.add(absolute); + } + + if (!matchedFile) unmatched.push(pattern); + } + + if (unmatched.length > 0) { + warn( + `Warning: \`includedPaths\` pattern(s) in config matched no files:\n` + + `${unmatched.map((pattern) => ` ${pattern}`).join('\n')}\n\n` + + `Patterns are resolved relative to ${workspaceRoot}.`, + ); + } + + const files = [...resolved].sort((a, b) => a.localeCompare(b)); + + let totalBytes = 0; + for (const file of files) { + try { + totalBytes += fs.statSync(file).size; + } catch { + // Raced away between glob and stat; the zip step reports it properly. + } + } + + if ( + files.length > INCLUDED_PATHS_FILE_WARN_THRESHOLD || + totalBytes > INCLUDED_PATHS_BYTES_WARN_THRESHOLD + ) { + warn( + `Warning: \`includedPaths\` matched ${files.length} file(s) totalling ` + + `${Math.round(totalBytes / (1024 * 1024))} MB. The flow archive is built in ` + + `memory, so consider narrowing the patterns.`, + ); + } + + if (debug) { + console.log( + `[DEBUG] includedPaths matched ${files.length} file(s):\n${files.join('\n')}`, + ); + } + + return files; +} + /** * Resolve sequential execution order from workspace config * @param workspaceConfig - Workspace configuration with executionOrder @@ -382,7 +515,7 @@ export async function plan(options: PlanOptions): Promise { } if (fs.lstatSync(normalizedInput).isFile()) { - return planSingleFile(normalizedInput, warn, resolvedConfigFile); + return planSingleFile(normalizedInput, warn, resolvedConfigFile, debug); } let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile); @@ -522,6 +655,12 @@ export async function plan(options: PlanOptions): Promise { flowMetadata, flowOverrides, flowsToRun: normalFlows, + includedFiles: resolveIncludedPaths( + workspaceConfig, + normalizedInput, + warn, + debug, + ), referencedFiles: [...new Set(allFiles)], sequence: { continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure, diff --git a/src/services/execution-plan.utils.ts b/src/services/execution-plan.utils.ts index dbb23ee..9fcc12c 100644 --- a/src/services/execution-plan.utils.ts +++ b/src/services/execution-plan.utils.ts @@ -9,7 +9,53 @@ import { WORKSPACE_CONFIG_KEYS, } from './workspace-config.schema.js'; -const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']); +const commandsThatRequireFiles = new Set([ + 'addMedia', + 'assertScreenshot', + 'runFlow', + 'runScript', +]); + +/** + * Commands whose file references are best-effort rather than mandatory. + * + * `assertScreenshot` baselines are legitimately absent on a first run, and + * Maestro's own "Screenshot file not found — searched in: …" error is more + * useful than ours, so a missing baseline must not abort the upload the way a + * missing `addMedia` file does. + */ +const commandsWithOptionalFiles = new Set(['assertScreenshot']); + +/** + * Extensions Maestro's `normalizeScreenshotPath` recognises; anything else + * gets `.png` appended, so `assertScreenshot: home` means `home.png`. + */ +const SCREENSHOT_EXTENSIONS = new Set([ + '.bmp', + '.gif', + '.heic', + '.heif', + '.jpeg', + '.jpg', + '.png', + '.tiff', + '.wbmp', +]); + +/** + * Mirror Maestro's `Orchestra.normalizeScreenshotPath`: a screenshot path with + * no image extension gets `.png`. Without this, `assertScreenshot: home` looks + * like a missing file here while resolving fine on the device. + * + * @param relativePath - The path as written in the flow + * @returns The path with an image extension guaranteed + */ +function normalizeScreenshotPath(relativePath: string): string { + const extension = path.extname(relativePath).toLowerCase(); + return SCREENSHOT_EXTENSIONS.has(extension) + ? relativePath + : `${relativePath}.png`; +} export function getFlowsToRunInSequence( paths: { [key: string]: string }, @@ -189,6 +235,8 @@ export const checkIfFilesExistInWorkspace = ( const errors: string[] = []; const files: string[] = []; const directory = path.dirname(absoluteFilePath); + const isScreenshot = commandName === 'assertScreenshot'; + const isOptional = commandsWithOptionalFiles.has(commandName); const buildError = (error: string) => `Flow file "${absoluteFilePath}" has a command "${commandName}" that references a ${error} ${JSON.stringify( @@ -196,11 +244,25 @@ export const checkIfFilesExistInWorkspace = ( )}`; const processFilePath = (relativePath: string) => { + // A JS/variable-interpolated path (`screenshots/${DCD_DEVICE}/home`) can't + // be resolved without running the flow. Skip it rather than guessing — the + // config.yaml `includedPaths` key is how those files get bundled. + if (relativePath.includes('${')) return; + + const resolvedRelativePath = isScreenshot + ? normalizeScreenshotPath(relativePath) + : relativePath; const absoluteFilePath = path.normalize( - path.resolve(directory, relativePath), + path.resolve(directory, resolvedRelativePath), ); const error = checkFile(absoluteFilePath); - if (error) errors.push(buildError(error)); + if (error) { + // Optional references drop out entirely when missing: pushing them onto + // `files` would put a non-existent path into the zip manifest. + if (isOptional) return; + errors.push(buildError(error)); + } + files.push(absoluteFilePath); }; @@ -216,9 +278,13 @@ export const checkIfFilesExistInWorkspace = ( } } - // object command + // object command. `file` is addMedia/runFlow/runScript; `path` is + // assertScreenshot's own key for the same thing. const x = command as Record; // prevent annoying ts error - if (typeof command === 'object' && x?.file) processFilePath(x.file); + if (typeof command === 'object' && !Array.isArray(command)) { + if (x?.file) processFilePath(x.file); + if (isScreenshot && typeof x?.path === 'string') processFilePath(x.path); + } return { errors, files }; }; diff --git a/src/services/flow-paths.ts b/src/services/flow-paths.ts index 195bdd3..9242aed 100644 --- a/src/services/flow-paths.ts +++ b/src/services/flow-paths.ts @@ -14,14 +14,25 @@ import { toPortableRelativePath } from '../utils/paths.js'; * file path. Segment comparison (not `startsWith`) so sibling dirs like * `flows`/`flows-extra` can't merge, and the file segment itself is never * consumed. Returns '' when the paths share no root at all (or none are given). + * + * `includedFiles` (config.yaml `includedPaths`) must be folded in for the same + * reason referenced files are: the zip strips this root as an anchored prefix, + * so a file outside it would get a non-relative entry name. Folding them in + * can raise the root — flows in `flows/` beside baselines in `screenshots/` + * shifts it from `/flows` to ``, so flow keys gain a `flows/` + * segment. That shift is what preserves the flow→baseline relative offset + * Maestro resolves against, and is already how `addMedia` behaves. */ export function computeCommonRoot( testFileNames: string[], referencedFiles: string[], + includedFiles: string[] = [], ): string { - const pathsShortestToLongest = [...testFileNames, ...referencedFiles].sort( - (a, b) => a.split(path.sep).length - b.split(path.sep).length, - ); + const pathsShortestToLongest = [ + ...testFileNames, + ...referencedFiles, + ...includedFiles, + ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length); if (pathsShortestToLongest.length === 0) return ''; const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep)); diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 4a5fe78..5e79036 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -124,6 +124,7 @@ export class TestSubmissionService { flowMetadata, flowOverrides, flowsToRun: testFileNames, + includedFiles = [], referencedFiles, sequence, workspaceConfig, @@ -168,6 +169,22 @@ export class TestSubmissionService { } } + // Logged separately from referencedFiles: these come from config.yaml's + // `includedPaths` rather than from a flow command, and they can raise the + // common root (see computeCommonRoot) — which shows up here as every flow + // key gaining a leading directory segment. + if (includedFiles.length > 0) { + this.logDebug( + debug, + logger, + `[DEBUG] Uploading ${includedFiles.length} file(s) from \`includedPaths\`:`, + ); + for (const file of includedFiles) { + const normalizedPath = this.normalizeFilePath(file, commonRoot); + this.logDebug(debug, logger, `[DEBUG] - ${normalizedPath}`); + } + } + this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`); const plaintextZip = await compressFilesFromRelativePath( @@ -179,6 +196,7 @@ export class TestSubmissionService { ...referencedFiles, ...testFileNames, ...sequentialFlows, + ...includedFiles, ]), ], commonRoot, diff --git a/src/services/workspace-config.schema.ts b/src/services/workspace-config.schema.ts index 960cee9..71c2a29 100644 --- a/src/services/workspace-config.schema.ts +++ b/src/services/workspace-config.schema.ts @@ -47,6 +47,7 @@ export const WorkspaceConfigSchema = z.looseObject({ excludeTags: tagList.nullish(), executionOrder: ExecutionOrderSchema.nullish(), flows: z.array(z.string()).nullish(), + includedPaths: z.array(z.string()).nullish(), includeTags: tagList.nullish(), local: z .looseObject({ deterministicOrder: z.boolean().nullish() }) @@ -92,10 +93,15 @@ export const WORKSPACE_CONFIG_KEYS: ReadonlySet = new Set( * Near-misses that aren't just a casing slip on a real key. Keyed lowercase. */ const KEY_ALIASES: Record = { + assets: 'includedPaths', continueonfailure: 'executionOrder.continueOnFailure', excludetag: 'excludeTags', + files: 'includedPaths', floworder: 'executionOrder.flowsOrder', flowsorder: 'executionOrder.flowsOrder', + includedpath: 'includedPaths', + includefiles: 'includedPaths', + includepaths: 'includedPaths', includetag: 'includeTags', tags: 'includeTags / excludeTags', }; diff --git a/test/unit/included-paths.test.ts b/test/unit/included-paths.test.ts new file mode 100644 index 0000000..3f36e5c --- /dev/null +++ b/test/unit/included-paths.test.ts @@ -0,0 +1,249 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { plan } from '../../src/services/execution-plan.service.js'; +import { + checkIfFilesExistInWorkspace, + isWorkspaceConfigFile, +} from '../../src/services/execution-plan.utils.js'; +import { computeCommonRoot } from '../../src/services/flow-paths.js'; +import { parseWorkspaceConfig } from '../../src/services/workspace-config.schema.js'; + +/** + * Fixtures are built on disk because `includedPaths` is glob-driven — a stubbed + * filesystem would test the stub, not `fs.globSync`'s actual matching. + * Paths are composed with `path.join` so the assertions hold on Windows too. + */ +function makeWorkspace( + files: Record, +): { cleanup: () => void; root: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-included-')); + for (const [relativePath, contents] of Object.entries(files)) { + const absolute = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, contents); + } + + return { cleanup: () => fs.rmSync(root, { recursive: true, force: true }), root }; +} + +const FLOW = ['appId: com.example', '---', '- launchApp'].join('\n'); + +describe('includedPaths', () => { + describe('schema', () => { + it('accepts includedPaths without warning', () => { + const warnings: string[] = []; + const config = parseWorkspaceConfig( + { includedPaths: ['screenshots/**'] }, + { filePath: 'config.yaml', warn: (m) => warnings.push(m) }, + ); + + expect(config.includedPaths).to.deep.equal(['screenshots/**']); + expect(warnings).to.deep.equal([]); + }); + + it('suggests includedPaths for near-miss keys', () => { + const warnings: string[] = []; + parseWorkspaceConfig( + { assets: ['screenshots/**'] }, + { filePath: 'config.yaml', warn: (m) => warnings.push(m) }, + ); + + expect(warnings.join('\n')).to.contain('did you mean includedPaths'); + }); + + it('counts as a workspace-config key for shape detection', () => { + const { cleanup, root } = makeWorkspace({ + 'config.yaml': 'includedPaths:\n - screenshots/**\n', + }); + + try { + expect(isWorkspaceConfigFile(path.join(root, 'config.yaml'))).to.equal( + true, + ); + } finally { + cleanup(); + } + }); + }); + + describe('resolution', () => { + it('bundles files no flow command references', async () => { + const { cleanup, root } = makeWorkspace({ + 'config.yaml': 'includedPaths:\n - screenshots/**\n', + 'screenshots/home.png': 'png-bytes', + 'visual.yaml': FLOW, + }); + + try { + const result = await plan({ input: root, warn: () => {} }); + expect(result.includedFiles).to.deep.equal([ + path.join(root, 'screenshots', 'home.png'), + ]); + // The baseline is NOT a flow-command reference — that is the whole + // point. referencedFiles holds only the flow its BFS was seeded with. + expect(result.referencedFiles).to.deep.equal([ + path.join(root, 'visual.yaml'), + ]); + } finally { + cleanup(); + } + }); + + it('skips directories that match the glob', async () => { + const { cleanup, root } = makeWorkspace({ + 'assets/nested/keep.png': 'png-bytes', + 'config.yaml': 'includedPaths:\n - assets/**\n', + 'visual.yaml': FLOW, + }); + + try { + const result = await plan({ input: root, warn: () => {} }); + expect(result.includedFiles).to.deep.equal([ + path.join(root, 'assets', 'nested', 'keep.png'), + ]); + } finally { + cleanup(); + } + }); + + it('warns when a pattern matches nothing', async () => { + const { cleanup, root } = makeWorkspace({ + 'config.yaml': 'includedPaths:\n - screenshots/**\n', + 'visual.yaml': FLOW, + }); + const warnings: string[] = []; + + try { + const result = await plan({ + input: root, + warn: (m) => warnings.push(m), + }); + expect(result.includedFiles).to.deep.equal([]); + expect(warnings.join('\n')).to.contain('matched no files'); + } finally { + cleanup(); + } + }); + + it('refuses a pattern that escapes the workspace', async () => { + const { cleanup, root } = makeWorkspace({ + 'config.yaml': 'includedPaths:\n - ../outside.png\n', + 'visual.yaml': FLOW, + }); + fs.writeFileSync(path.join(root, '..', 'outside.png'), 'png-bytes'); + + try { + await plan({ input: root, warn: () => {} }); + expect.fail('expected the containment guard to throw'); + } catch (error) { + expect((error as Error).message).to.contain( + 'resolves outside the workspace', + ); + } finally { + cleanup(); + } + }); + }); + + describe('computeCommonRoot', () => { + it('folds included files in so the zip can strip the prefix', () => { + const flows = [path.join('/a', 'b', 'flows', 'login.yaml')]; + const included = [path.join('/a', 'b', 'screenshots', 'home.png')]; + + // Without the included file the root sits at the flows dir; folding it in + // raises the root so the flow -> baseline relative offset survives the zip. + expect(computeCommonRoot(flows, [])).to.equal( + path.join('/a', 'b', 'flows'), + ); + expect(computeCommonRoot(flows, [], included)).to.equal( + path.join('/a', 'b'), + ); + }); + + it('defaults includedFiles so existing callers are unaffected', () => { + const flows = [path.join('/a', 'b', 'login.yaml')]; + expect(computeCommonRoot(flows, [])).to.equal(path.join('/a', 'b')); + }); + }); + + describe('assertScreenshot dependency walking', () => { + const flowPath = path.join('/ws', 'visual.yaml'); + + it('does not error on a missing baseline', () => { + const { errors, files } = checkIfFilesExistInWorkspace( + 'assertScreenshot', + 'screenshots/home.png', + flowPath, + ); + + // Maestro's own "searched in:" message is better than ours, and a first + // run legitimately has no baseline — so this must not abort the upload. + expect(errors).to.deep.equal([]); + expect(files).to.deep.equal([]); + }); + + it('still errors on a missing addMedia file', () => { + const { errors } = checkIfFilesExistInWorkspace( + 'addMedia', + 'screenshots/home.png', + flowPath, + ); + expect(errors).to.have.lengthOf(1); + }); + + it('bundles an existing baseline referenced by the object path key', () => { + const { cleanup, root } = makeWorkspace({ + 'screenshots/home.png': 'png-bytes', + 'visual.yaml': FLOW, + }); + + try { + const { errors, files } = checkIfFilesExistInWorkspace( + 'assertScreenshot', + { path: 'screenshots/home.png' }, + path.join(root, 'visual.yaml'), + ); + expect(errors).to.deep.equal([]); + expect(files).to.deep.equal([path.join(root, 'screenshots', 'home.png')]); + } finally { + cleanup(); + } + }); + + it('appends .png when the path has no image extension', () => { + const { cleanup, root } = makeWorkspace({ + 'screenshots/home.png': 'png-bytes', + 'visual.yaml': FLOW, + }); + + try { + // Mirrors Maestro's normalizeScreenshotPath: `assertScreenshot: home` + // resolves to home.png on the device, so it must here too. + const { files } = checkIfFilesExistInWorkspace( + 'assertScreenshot', + 'screenshots/home', + path.join(root, 'visual.yaml'), + ); + expect(files).to.deep.equal([path.join(root, 'screenshots', 'home.png')]); + } finally { + cleanup(); + } + }); + + it('skips an interpolated path rather than guessing', () => { + const { errors, files } = checkIfFilesExistInWorkspace( + 'assertScreenshot', + 'screenshots/${DCD_DEVICE}/home.png', + flowPath, + ); + + // Per-device baselines are what `includedPaths` is for; a static walk + // cannot resolve the variable. + expect(errors).to.deep.equal([]); + expect(files).to.deep.equal([]); + }); + }); +}); From 83d9ccc10ed45212e33f7cb619c76de999c98382 Mon Sep 17 00:00:00 2001 From: "dcd-cli-release-please[bot]" <296541543+dcd-cli-release-please[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:06:23 +0100 Subject: [PATCH 2/6] chore(dev): release 5.6.0-beta.2 (#175) Co-authored-by: dcd-cli-release-please[bot] <296541543+dcd-cli-release-please[bot]@users.noreply.github.com> --- .release-please-manifest-beta.json | 2 +- CHANGELOG-beta.md | 12 ++++++++++++ package.json | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index a52ae01..9330407 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "5.6.0-beta.1" + ".": "5.6.0-beta.2" } diff --git a/CHANGELOG-beta.md b/CHANGELOG-beta.md index 4161d22..c6de11d 100644 --- a/CHANGELOG-beta.md +++ b/CHANGELOG-beta.md @@ -1,5 +1,17 @@ # Changelog +## [5.6.0-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.6.0-beta.1...v5.6.0-beta.2) (2026-09-18) + + +### Features + +* **config:** add includedPaths to ship non-flow files ([#178](https://github.com/devicecloud-dev/dcd-cli/issues/178)) ([aaf91a1](https://github.com/devicecloud-dev/dcd-cli/commit/aaf91a1ee5688bfbd261f3360d5ec4ca8e5ceb0d)) + + +### Bug Fixes + +* v5 release blockers — installer, binary version, repeated flags, upgrade, CI output ([80eafc6](https://github.com/devicecloud-dev/dcd-cli/commit/80eafc671fdbf87e5efb3416d48b300b16b71bb4)) + ## [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) diff --git a/package.json b/package.json index d4b42bf..d638cb5 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.6.0-beta.1", + "version": "5.6.0-beta.2", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, From 9b0f179628b576a25dd94255e282a67a77f807cc Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:24:26 +0100 Subject: [PATCH 3/6] ci: drop setup-node registry-url so OIDC publishing works (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry-url makes setup-node write `_authToken=${NODE_AUTH_TOKEN}` into .npmrc. With no token set that resolves to an empty credential, so npm treats auth as configured, skips the trusted-publishing OIDC exchange and PUTs unauthenticated — which npm answers with 404. That is why 5.6.0-beta.1 and beta.2 never published. npmjs.org is the default registry, so nothing else changes. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/npm-publish.yml | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 8e1eef7..ce9165d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -23,16 +23,19 @@ 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. + # 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 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: @@ -42,10 +45,19 @@ 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' From 6e720fbf4d596ba3d0f9000a049ed2343f166466 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:37:06 +0100 Subject: [PATCH 4/6] chore: re-cut the beta to verify npm trusted publishing (#180) 5.6.0-beta.1 and beta.2 were tagged and released but never reached npm: publish-npm-beta failed with `E404 PUT /@devicecloud.dev%2fdcd` because setup-node's registry-url wrote an empty auth token, so npm never ran the OIDC exchange. #179 removed it; nothing releasable has landed since, so this empty commit forces a cut to prove the fix. Release-As: 5.6.0-beta.3 From 9101b215a2870c985d283ffa2f5afd4f2ac2bdf1 Mon Sep 17 00:00:00 2001 From: "dcd-cli-release-please[bot]" <296541543+dcd-cli-release-please[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:42:36 +0100 Subject: [PATCH 5/6] chore(dev): release 5.6.0-beta.3 (#182) Co-authored-by: dcd-cli-release-please[bot] <296541543+dcd-cli-release-please[bot]@users.noreply.github.com> --- .release-please-manifest-beta.json | 2 +- CHANGELOG-beta.md | 7 +++++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 9330407..fb5f7f8 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "5.6.0-beta.2" + ".": "5.6.0-beta.3" } diff --git a/CHANGELOG-beta.md b/CHANGELOG-beta.md index c6de11d..c0ba3f1 100644 --- a/CHANGELOG-beta.md +++ b/CHANGELOG-beta.md @@ -1,5 +1,12 @@ # Changelog +## [5.6.0-beta.3](https://github.com/devicecloud-dev/dcd-cli/compare/v5.6.0-beta.2...v5.6.0-beta.3) (2026-09-18) + + +### Miscellaneous + +* re-cut the beta to verify npm trusted publishing ([#180](https://github.com/devicecloud-dev/dcd-cli/issues/180)) ([6e720fb](https://github.com/devicecloud-dev/dcd-cli/commit/6e720fbf4d596ba3d0f9000a049ed2343f166466)) + ## [5.6.0-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.6.0-beta.1...v5.6.0-beta.2) (2026-09-18) diff --git a/package.json b/package.json index d638cb5..ea0c840 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.6.0-beta.2", + "version": "5.6.0-beta.3", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, From 33158c51295a5ad6abaec6f5ecfe026a7b197160 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:56:01 +0100 Subject: [PATCH 6/6] ci: cut betas by hand and require a Release-As pin on promotions (#181) Betas move from a second release-please track on `dev` to a dispatched `Release beta` workflow that derives the version from the npm registry, so a beta can no longer sort below `@latest` the way 5.0.0, 5.2.0 and 5.5.0 did. The beta config, manifest and npm-publish beta path go with it; CHANGELOG-beta is frozen at 5.6.0-beta.2. Stable is unchanged apart from a new back-merge PR after each release, and a `promotion-pin` CI check: a promotion is a merge, so every beta tag is reachable from production and release-please picks one as its base unless the stable version is pinned. That is what left production on 5.6.0-beta.1. Publishing now happens before the tag and GitHub release are created, so a failed publish no longer strands a release with no package on npm. --- .github/workflows/cli-ci.yml | 66 +++++++++ .github/workflows/npm-publish.yml | 84 ++++------- .github/workflows/release-beta.yml | 194 +++++++++++++++++++++++++ .github/workflows/release-binaries.yml | 14 +- .github/workflows/release-please.yml | 132 +++++++++-------- .release-please-manifest-beta.json | 3 - CHANGELOG-beta.md | 6 + CLAUDE.md | 36 +++-- CONTRIBUTING.md | 20 ++- release-please-config-beta.json | 32 ---- scripts/next-beta-version.mjs | 127 ++++++++++++++++ 11 files changed, 541 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/release-beta.yml delete mode 100644 .release-please-manifest-beta.json delete mode 100644 release-please-config-beta.json create mode 100644 scripts/next-beta-version.mjs diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 539f1c1..3ec9ff9 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -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 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index ce9165d..b2a74b7 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -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: @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml new file mode 100644 index 0000000..cb5f7b1 --- /dev/null +++ b/.github/workflows/release-beta.yml @@ -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" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 16bb21b..f8cbd7a 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -57,15 +57,11 @@ jobs: echo "name=v${VERSION}" >> "$GITHUB_OUTPUT" fi + # 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. release-beta.yml uploads the same way. - name: Upload binaries to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${{ steps.tag.outputs.name }}" \ - dist-bin/dcd-darwin-arm64 \ - dist-bin/dcd-darwin-x64 \ - dist-bin/dcd-linux-arm64 \ - dist-bin/dcd-linux-x64 \ - dist-bin/dcd-windows-x64.exe \ - dist-bin/SHA256SUMS \ - --clobber + TAG: ${{ steps.tag.outputs.name }} + run: gh release upload "$TAG" dist-bin/* --clobber diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 45f4d84..48e012c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,17 +1,19 @@ name: Release -# Drives the full release pipeline: -# - Push to `production` → stable release (no -beta suffix, npm `latest` tag). -# - Push to `dev` → beta release (e.g. 5.1.0-beta.0, npm `beta` tag, GitHub -# "pre-release" badge). -# In both cases release-please opens/updates a Release PR; merging that PR -# creates the tag + GitHub Release whose body is the freshly-rendered changelog -# section, and the same workflow run chains into npm publish + binary uploads. -# (Chained as jobs because GITHUB_TOKEN-created releases don't trigger -# workflows listening on `release: published`.) +# Drives the STABLE release pipeline. A push to `production` makes release-please +# open/update a Release PR; merging that PR creates the tag + GitHub Release whose +# body is the freshly-rendered changelog section, and the same workflow run chains +# into npm publish + binary uploads. (Chained as jobs because GITHUB_TOKEN-created +# releases don't trigger workflows listening on `release: published`.) +# +# Betas are NOT handled here — they are cut by hand from +# .github/workflows/release-beta.yml. They used to be a second release-please +# track on `dev` with its own config and manifest, which numbered itself without +# reference to what stable had shipped and so kept publishing betas that sorted +# below `@latest`. See that workflow's header for the full story. on: push: - branches: [production, dev] + branches: [production] workflow_dispatch: permissions: @@ -49,32 +51,6 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - release-please-beta: - if: github.ref_name == 'dev' - runs-on: ubuntu-latest - # See release-please-prod above for why this uses an App token with a - # GITHUB_TOKEN fallback. - env: - BOT_APP_ID: ${{ secrets.BOT_APP_ID }} - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - version: ${{ steps.release.outputs.version }} - steps: - - uses: actions/create-github-app-token@v3 - id: app-token - if: env.BOT_APP_ID != '' - with: - app-id: ${{ secrets.BOT_APP_ID }} - private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - uses: googleapis/release-please-action@v5 - id: release - with: - token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} - target-branch: dev - config-file: release-please-config-beta.json - 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 @@ -86,8 +62,6 @@ jobs: needs: release-please-prod if: needs.release-please-prod.outputs.release_created == 'true' uses: ./.github/workflows/npm-publish.yml - with: - release_type: prod secrets: inherit publish-binaries-prod: @@ -98,25 +72,67 @@ jobs: tag_name: ${{ needs.release-please-prod.outputs.tag_name }} 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. + # Stable releases land on `production` and were never merged back, so `dev` + # accumulated a permanent one-way divergence: 26 commits at the last count, + # every one of them release bookkeeping, growing by ~3 per release forever. + # That also left `dev`'s package.json and CHANGELOG.md stale between releases. + # This opens the back-merge automatically so the next promotion's diff is only + # the commits since this release. + back-merge: + needs: release-please-prod + if: needs.release-please-prod.outputs.release_created == 'true' + runs-on: ubuntu-latest 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 - with: - release_type: beta - secrets: inherit + contents: write + pull-requests: write + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + steps: + # See release-please-prod for why this prefers an App token: a PR opened by + # GITHUB_TOKEN does not trigger the required checks, and `dev`'s `Require + # CI` ruleset has NO bypass actors, so such a PR can never be merged. + - uses: actions/create-github-app-token@v3 + id: app-token + if: env.BOT_APP_ID != '' + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - publish-binaries-beta: - needs: release-please-beta - if: needs.release-please-beta.outputs.release_created == 'true' - uses: ./.github/workflows/release-binaries.yml - with: - tag_name: ${{ needs.release-please-beta.outputs.tag_name }} - secrets: inherit + - uses: actions/checkout@v7 + with: + ref: dev + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Open the back-merge PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.release-please-prod.outputs.version }} + run: | + set -euo pipefail + BRANCH="chore/back-merge-${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + + # A merge commit, never a squash or rebase: it is what keeps `dev` and + # `production` ancestors of each other. Rewriting the commits here is + # what left the two branches permanently divergent before 5.5.0. + if ! git merge --no-ff origin/production -m "chore: merge production into dev after ${VERSION}"; then + echo "Back-merge of production into dev conflicts — resolve it by hand." >&2 + git merge --abort || true + exit 1 + fi + + if git diff --quiet "origin/dev..HEAD"; then + echo "dev already contains production; nothing to back-merge." + exit 0 + fi + + git push origin "$BRANCH" + gh pr create \ + --base dev \ + --head "$BRANCH" \ + --title "chore: merge production into dev after ${VERSION}" \ + --body "Back-merge of the ${VERSION} stable release so \`dev\` carries the released package.json, CHANGELOG.md and manifest. Opened automatically by the Release workflow." diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json deleted file mode 100644 index fb5f7f8..0000000 --- a/.release-please-manifest-beta.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "5.6.0-beta.3" -} diff --git a/CHANGELOG-beta.md b/CHANGELOG-beta.md index c0ba3f1..8159ec1 100644 --- a/CHANGELOG-beta.md +++ b/CHANGELOG-beta.md @@ -1,5 +1,11 @@ # Changelog +> **Frozen.** Betas are no longer cut by release-please, so nothing appends to +> this file any more. They are published on demand from the `Release beta` +> workflow, and each one's notes live on its GitHub prerelease. +> Entries below are kept as the historical record of the beta line up to +> 5.6.0-beta.3. + ## [5.6.0-beta.3](https://github.com/devicecloud-dev/dcd-cli/compare/v5.6.0-beta.2...v5.6.0-beta.3) (2026-09-18) diff --git a/CLAUDE.md b/CLAUDE.md index d1a558c..0bf72db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,23 +59,37 @@ Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones tha - PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it. - Type → bump: `feat` **minor**; `fix`/`perf`/`deps`/`revert`/`refactor` **patch**; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form. - ⚠️ **A `!` (or `BREAKING CHANGE:` footer) bumps the MAJOR — do not use it casually.** The configs set `bump-minor-pre-major: true`, but that only applies **below 1.0.0**; we are on 5.x, so it is inert and a breaking marker means exactly what semver says. A `refactor(cloud)!:` PR title once produced a `6.0.0-beta.1` release PR for what was only a flag rename in an unconsumed beta. Because PRs are squash-merged, **the PR title IS the commit** — the `!` lands even if no branch commit carried it. -- **Never hand-edit `package.json` version, `CHANGELOG.md` / `CHANGELOG-beta.md`, or the `.release-please-manifest*.json` files** — release-please owns all of them. `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). +- **Never hand-edit `package.json` version, `CHANGELOG.md`, or `.release-please-manifest.json`** — release-please owns all three, and hand-editing them is what left the 5.6.0 release PR internally inconsistent. (`CHANGELOG-beta.md` is frozen; nothing writes it any more.) `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). - A first-time contributor must sign the CLA (the CLA Assistant bot comments on the first PR); the CLA check must be green to merge. - **CI (`.github/workflows/cli-ci.yml`) runs the same steps on every PR** — fork, Dependabot and same-repo alike, with no privileged path: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm test:unit`, `pnpm build`, `pnpm audit --audit-level moderate`. **`test/integration/*` is not run by CI at all** (see the Commands section: this public repo no longer reaches into the private `devicecloud-dev/dcd` repo for a mock API), so a green PR says nothing about the integration suite — run it locally with `MOCK_API_DIR` set if a change touches the API surface. gitleaks also runs as a pre-commit hook (allowlist in `.gitleaks.toml`); without the binary installed the hook self-skips and CI is the backstop. ## Releases -Fully automated by [release-please](https://github.com/googleapis/release-please) — no manual version bumping. `.github/workflows/release-please.yml` drives **two parallel tracks off two separate config+manifest pairs**: +**Stable releases are automatic; betas are cut by hand.** -| Push to | Track | Config / manifest | Version | npm tag | -| --- | --- | --- | --- | --- | -| `dev` | **beta** (prerelease) | `release-please-config-beta.json` / `.release-please-manifest-beta.json` | `X.Y.Z-beta.N` | `beta` | -| `production` | **stable** | `release-please-config.json` / `.release-please-manifest.json` | `X.Y.Z` | `latest` | +| Line | Trigger | Version | npm tag | +| --- | --- | --- | --- | +| **stable** | push to `production` -> release-please opens a Release PR; merging it tags, publishes and uploads binaries | `X.Y.Z` | `latest` | +| **beta** | run the **Release beta** workflow (`release-beta.yml`) by hand from `dev` | `X.Y.Z-beta.N` | `beta` | -The two tracks also keep **separate changelog files** — beta writes `CHANGELOG-beta.md`, stable writes `CHANGELOG.md` — so a promotion never conflicts on them. The two manifests track their versions **independently** (e.g. beta `5.0.0-beta.3` while stable is `5.0.0`). On each qualifying push release-please opens/updates a **Release PR** on that branch; merging the Release PR creates the git tag + GitHub Release, and the same workflow run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into: -1. `npm-publish.yml` — publishes to npm. Guards: a prod publish may only run from `production` and its version must **not** carry `-beta`; a beta version **must** carry `-beta`. -2. `release-binaries.yml` — bun-compiles the standalone binaries (`node scripts/build-binaries.mjs`) and uploads them to the GitHub Release. `get.devicecloud.dev` serves them by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. +Stable is driven by `release-please.yml` off `release-please-config.json` / `.release-please-manifest.json`, writing `CHANGELOG.md`. Merging the Release PR creates the tag + GitHub Release and the same run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into `npm-publish.yml` and `release-binaries.yml`. `get.devicecloud.dev` serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. After a stable release the workflow also opens a **back-merge PR** into `dev`, so the two branches don't drift apart the way they did up to 5.5.0. -**Promoting beta → stable** is a maintainer opening a PR from `dev` into `production` and merging it with a **merge commit** — that push to `production` is what triggers the stable Release PR. Use a merge commit, never squash or rebase: the merge is what makes `production` a descendant of `dev`, so the two branches stay reconcilable and the next promotion's diff is only the commits since the last one. A squash or rebase promotion rewrites the commits, leaves the histories permanently divergent, and forces the next promotion to be reconstructed by hand — that is exactly what the pre-5.5.0 promotions did. +Betas have **no release-please track and no manifest**. `release-beta.yml` derives the version from the npm registry (`scripts/next-beta-version.mjs`): it bumps the published `latest` and appends the next `-beta.N`, so a beta is always above the current stable. There was a second release-please track until 5.6.0, and because its manifest numbered itself with no reference to what stable had shipped, `@beta` ended up *older* than `@latest` three times (5.0.0, 5.2.0, 5.5.0) — each needing a hand-written `Release-As` to escape. `CHANGELOG-beta.md` is frozen at 5.6.0-beta.2; beta notes now live on the GitHub prerelease. -The only conflict a promotion should raise is the `version` line in `package.json` (beta on one side, stable on the other). **Take `dev`'s** — release-please overwrites it from `.release-please-manifest.json` when the Release PR lands. Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR triggers the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured. +**Promoting beta -> stable** is a maintainer opening a PR from `dev` into `production` and merging it with a **merge commit** — that push to `production` is what triggers the stable Release PR. Use a merge commit, never squash or rebase: the merge is what makes `production` a descendant of `dev`, so the two branches stay reconcilable and the next promotion's diff is only the commits since the last one. A squash or rebase promotion rewrites the commits, leaves the histories permanently divergent, and forces the next promotion to be reconstructed by hand — that is exactly what the pre-5.5.0 promotions did. + +### Every promotion must carry a `Release-As:` pin + +Put the stable version on its own commit in the promotion branch: + +``` +git commit --allow-empty -m "chore: pin the X.Y.Z promotion" -m "Release-As: X.Y.Z" +``` + +It must be an ordinary commit, not the merge commit — release-please's commit splitting is unreliable on merges. The `promotion-pin` check in `cli-ci.yml` enforces this. + +**Do not skip it on the grounds that the conventional commits since the last stable already imply the right bump.** They don't. A promotion is a merge, so every beta tag becomes reachable from `production`, and release-please takes the newest reachable tag as its base — which is a `-beta` one. The 5.6.0 promotion reasoned exactly that way, skipped the pin, and produced a `chore(production): release 5.6.0-beta.1` release PR while leaving `production` carrying `5.6.0-beta.1` in `package.json` against a stable manifest still reading `5.5.0`. `npm-publish.yml`'s version guard then refuses to publish, which is the intended backstop, not the fix. + +Also **keep `production`'s `version` line** when resolving the promotion's `package.json` conflict, not `dev`'s. Taking `dev`'s puts a prerelease on the stable branch for as long as the Release PR is open; release-please rewrites it from the manifest when that PR lands, but if the PR stalls — as 5.6.0's did — the stable branch sits on a `-beta`. + +Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR and the back-merge PR trigger the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78f47ab..562769f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,8 +113,9 @@ Allowed types and how they affect the next release: | `style` | Formatting, whitespace | hidden | none | **Breaking changes:** append `!` after the type (e.g. `feat!: drop Node 20`) or -add a `BREAKING CHANGE:` footer in the PR description. While the CLI is pre-1.0, -`feat` bumps the minor version and breaking changes bump the minor too. +add a `BREAKING CHANGE:` footer in the PR description. The CLI is on 5.x, so this +bumps the **major** version — please don't reach for it casually. (The configs set +`bump-minor-pre-major`, but that only applies below 1.0.0 and is inert here.) Examples: @@ -139,17 +140,20 @@ deps: bump @modelcontextprotocol/sdk to 1.x You don't need to do anything for releases — **do not bump the version in `package.json` or edit `CHANGELOG.md`** in your PR. -Releases are automated by [release-please](https://github.com/googleapis/release-please): - -- Merges to `dev` accumulate into a **beta** release (published to npm under the - `beta` tag). -- Maintainers promote `dev` → `production` for **stable** releases (npm `latest`), - always with a **merge commit** so the two branches stay in sync. +Stable releases are automated by +[release-please](https://github.com/googleapis/release-please): maintainers +promote `dev` → `production` (always with a **merge commit**, so the two branches +stay in sync), which opens a Release PR; merging that publishes to npm under the +`latest` tag. release-please reads the Conventional Commit titles of merged PRs to compute the next version and generate the changelog — which is exactly why the PR title convention matters. +Betas are published on demand by a maintainer running the **Release beta** +workflow, so your change reaches npm's `beta` tag whenever the next one is cut +rather than automatically on merge. + ## Questions - General questions and help: [Discord](https://discord.gg/gm3mJwcNw8). diff --git a/release-please-config-beta.json b/release-please-config-beta.json deleted file mode 100644 index 2dfdfed..0000000 --- a/release-please-config-beta.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "node", - "include-v-in-tag": true, - "include-component-in-tag": false, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false, - "draft": false, - "prerelease": true, - "prerelease-type": "beta", - "versioning": "prerelease", - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance" }, - { "type": "deps", "section": "Dependencies" }, - { "type": "revert", "section": "Reverts" }, - { "type": "refactor", "section": "Code Refactoring" }, - { "type": "docs", "section": "Documentation", "hidden": true }, - { "type": "chore", "section": "Miscellaneous", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "style", "section": "Styles", "hidden": true } - ], - "packages": { - ".": { - "package-name": "@devicecloud.dev/dcd", - "changelog-path": "CHANGELOG-beta.md" - } - } -} diff --git a/scripts/next-beta-version.mjs b/scripts/next-beta-version.mjs new file mode 100644 index 0000000..9d4a100 --- /dev/null +++ b/scripts/next-beta-version.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Resolve the version for a manual beta release (.github/workflows/release-beta.yml). + * + * The beta line has no release-please manifest any more, so the npm registry is + * the only state: whatever is published IS the source of truth. That is + * deliberate. A manifest that numbered itself independently of what had actually + * shipped is exactly how `@beta` ended up *older* than `@latest` three times + * (5.0.0, 5.2.0 and 5.5.0) — each time needing a hand-written `Release-As` to + * dig out. Deriving from the registry makes that impossible by construction: + * the base is always a bump of the current `latest`, so a beta can never sort + * below the stable it is meant to preview. + * + * Usage: + * node scripts/next-beta-version.mjs [--bump patch|minor|major] [--version X.Y.Z-beta.N] + * + * Prints the resolved version to stdout, and appends `version=` to + * $GITHUB_OUTPUT when that is set. + */ +const PACKAGE = '@devicecloud.dev/dcd'; +const REGISTRY = 'https://registry.npmjs.org/@devicecloud.dev%2Fdcd'; + +// Deliberately NOT a general semver implementation — this package only ever +// publishes `X.Y.Z` and `X.Y.Z-beta.N`, and anything else reaching here means a +// wrong assumption somewhere upstream that should fail loudly rather than be +// silently coerced. +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; +const BETA = /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/; + +function parse(version) { + const stable = STABLE.exec(version); + if (stable) { + const [, major, minor, patch] = stable; + return { major: +major, minor: +minor, patch: +patch, beta: null }; + } + const beta = BETA.exec(version); + if (beta) { + const [, major, minor, patch, n] = beta; + return { major: +major, minor: +minor, patch: +patch, beta: +n }; + } + return null; +} + +/** -1 / 0 / 1. A prerelease sorts below the release it precedes. */ +function compare(a, b) { + for (const part of ['major', 'minor', 'patch']) { + if (a[part] !== b[part]) return a[part] < b[part] ? -1 : 1; + } + if (a.beta === b.beta) return 0; + if (a.beta === null) return 1; + if (b.beta === null) return -1; + return a.beta < b.beta ? -1 : 1; +} + +function bumpBase({ major, minor, patch }, kind) { + if (kind === 'major') return `${major + 1}.0.0`; + if (kind === 'minor') return `${major}.${minor + 1}.0`; + if (kind === 'patch') return `${major}.${minor}.${patch + 1}`; + throw new Error(`Unknown bump "${kind}" (expected patch, minor or major)`); +} + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return ''; + return (process.argv[i + 1] ?? '').trim(); +} + +const requested = arg('version'); +const bump = arg('bump') || 'minor'; + +// The registry, not `npm view` — npm's CLI serves stale cached metadata for +// minutes after a publish, which would hand back a version number that is +// already taken. +const response = await fetch(REGISTRY, { + headers: { accept: 'application/vnd.npm.install-v1+json' }, +}); +if (!response.ok) { + fail(`registry returned ${response.status} ${response.statusText} for ${PACKAGE}`); +} +const packument = await response.json(); + +const latestRaw = packument['dist-tags']?.latest; +if (!latestRaw) fail(`${PACKAGE} has no "latest" dist-tag`); +const latest = parse(latestRaw); +if (!latest) fail(`could not parse the published latest version "${latestRaw}"`); +if (latest.beta !== null) { + fail(`the "latest" dist-tag points at a prerelease (${latestRaw}); fix that before cutting a beta`); +} + +const published = new Set(Object.keys(packument.versions ?? {})); + +let version; +if (requested) { + const parsed = parse(requested); + if (!parsed) fail(`"${requested}" is not a valid X.Y.Z-beta.N version`); + if (parsed.beta === null) fail(`"${requested}" is not a beta version`); + if (compare(parsed, latest) <= 0) { + fail(`"${requested}" is not above the published latest (${latestRaw}); @beta must never be older than @latest`); + } + version = requested; +} else { + const base = bumpBase(latest, bump); + const baseParsed = parse(base); + let highest = 0; + for (const candidate of published) { + const parsed = parse(candidate); + if (!parsed || parsed.beta === null) continue; + if (compare({ ...parsed, beta: null }, baseParsed) !== 0) continue; + if (parsed.beta > highest) highest = parsed.beta; + } + version = `${base}-beta.${highest + 1}`; +} + +if (published.has(version)) { + fail(`${version} is already published — npm versions can never be reused`); +} + +console.log(version); +if (process.env.GITHUB_OUTPUT) { + const { appendFileSync } = await import('node:fs'); + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); +}