diff --git a/.claude/skills/bump-coana/SKILL.md b/.claude/skills/bump-coana/SKILL.md index 830361b02..25758ee82 100644 --- a/.claude/skills/bump-coana/SKILL.md +++ b/.claude/skills/bump-coana/SKILL.md @@ -55,7 +55,19 @@ advance. **Values to extract**: - `CURRENT_VERSION`: The old Coana CLI version (for PR body) -### Step 3: Update CHANGELOG.md +### Step 3: Update the native help snapshots + +Some native help snapshots show the pinned Coana version, and native CI fails +until they match the pin. Move them to the new version: + +```bash +node scripts/test/swap-snapshot-version.mts --dep coana --from "$CURRENT_VERSION" +``` + +If it reports that a snapshot doesn't show `CURRENT_VERSION` or a row has no +room, run `pnpm snapshots:update:dep coana` instead. + +### Step 4: Update CHANGELOG.md Do not hand-edit the file. Run the helper, which owns the shape: @@ -93,20 +105,20 @@ if it finds one; hand-editing is what puts one there.) - Updated the Coana CLI to v `COANA_VERSION`. ``` -### Step 4: Create Branch and Commit +### Step 5: Create Branch and Commit ```bash # Create branch git checkout -b "coana-$COANA_VERSION" # Stage changes -git add src/constants.mts CHANGELOG.md +git add src/constants.mts CHANGELOG.md test/native-snapshots # Commit (skip pre-commit hooks with -n) git commit -n -m "upgrading coana to version $COANA_VERSION" ``` -### Step 5: Push and Create PR +### Step 6: Push and Create PR ```bash # Push branch @@ -130,7 +142,8 @@ Replace `CURRENT_VERSION` and `COANA_VERSION` with actual values. - Branch: `coana-` pushed to origin - PR: Created targeting `v1.x` branch - Files modified: `src/constants.mts` (the `COANA_CLI_VERSION` line only), - `CHANGELOG.md` (under `## [Unreleased]`) + `CHANGELOG.md` (under `## [Unreleased]`), and the Coana help snapshots in + `test/native-snapshots/` Report the PR URL to the user when complete. diff --git a/.gitignore b/.gitignore index 24b7ae1f1..33260765d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ !/scripts/test/terminal-report.mts !/scripts/test/snapshots.mts !/scripts/test/snapshot-dependencies.mts +!/scripts/test/swap-snapshot-version.mts !/scripts/utils/ !/shadow-bin/ !/src/ @@ -253,6 +254,7 @@ !/test/native-snapshots/** !/test/unit/scripts/test/snapshots.test.mts !/test/unit/scripts/test/snapshot-dependencies.test.mts +!/test/unit/scripts/test/swap-snapshot-version.test.mts !/test/package-matrix.test.mts !/test/fixtures/ !/test/fixtures/commands/ diff --git a/scripts/test/snapshot-dependencies.mts b/scripts/test/snapshot-dependencies.mts index 55d992767..8d221dd2c 100644 --- a/scripts/test/snapshot-dependencies.mts +++ b/scripts/test/snapshot-dependencies.mts @@ -55,6 +55,19 @@ export interface StaleSnapshotDependency { readonly snapshots: readonly string[] } +export async function readPinnedVersion( + dependency: SnapshotDependency, +): Promise { + const source = await fs.readFile(path.join(ROOT, dependency.source), 'utf8') + const version = dependency.versionPattern.exec(source)?.[1] + if (!version) { + throw new Error( + `Could not read ${dependency.packageName} version from ${dependency.source}`, + ) + } + return version +} + export async function findStaleSnapshotDependencies(): Promise< StaleSnapshotDependency[] > { @@ -63,16 +76,7 @@ export async function findStaleSnapshotDependencies(): Promise< string, SnapshotDependency, ]>).map(async ([name, dependency]) => { - const source = await fs.readFile( - path.join(ROOT, dependency.source), - 'utf8', - ) - const version = dependency.versionPattern.exec(source)?.[1] - if (!version) { - throw new Error( - `Could not read ${dependency.packageName} version from ${dependency.source}`, - ) - } + const version = await readPinnedVersion(dependency) const versionSnapshots = dependency.versionSnapshots ?? dependency.snapshots const snapshots = await Promise.all( versionSnapshots.map(async snapshot => ({ diff --git a/scripts/test/swap-snapshot-version.mts b/scripts/test/swap-snapshot-version.mts new file mode 100644 index 000000000..ee2a12ac7 --- /dev/null +++ b/scripts/test/swap-snapshot-version.mts @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * @file Move a pinned tool's native snapshots from its old version to the + * pinned one by editing the version text, for automation that bumps the pin + * but cannot build the native CLI. + * + * Usage: + * node scripts/test/swap-snapshot-version.mts --dep coana --from 15.15.0 + */ + +import { promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { + SNAPSHOT_DEPENDENCIES, + findStaleSnapshotDependencies, + normalizeSnapshotDependencyName, + readPinnedVersion, + snapshotDependencyUpdateHint, +} from './snapshot-dependencies.mts' +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +import type { SnapshotDependency } from './snapshot-dependencies.mts' +import type { ScriptMeta } from '../lib/run-main.mts' + +const ROOT = path.resolve(import.meta.dirname, '../..') + +export interface SwapArgs { + readonly dependencyName: string + readonly from: string +} + +export function parseSwapArgs(argv: readonly string[]): SwapArgs { + const { values } = parseArgs({ + args: [...argv], + options: { dep: { type: 'string' }, from: { type: 'string' } }, + strict: true, + }) + if (!values.dep || !values.from) { + throw new Error('Pass both --dep and --from ') + } + return { dependencyName: values.dep, from: values.from } +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Replace `from` with `to` in a terminal snapshot, taking or giving the + * difference from the spaces that pad the row, so every row keeps its column + * count and the rendered pixels match a real capture. + */ +export function swapVersionInSnapshot( + contents: string, + from: string, + to: string, +): string { + const pattern = new RegExp( + `(? { + found = true + const width = from.length + padding.length + if (to.length > width) { + throw new Error(`No room in the row to replace ${from} with ${to}`) + } + return `${to}${tail}${' '.repeat(width - to.length)}` + }, + ) + if (!found) { + throw new Error(`The snapshot does not mention ${from}`) + } + return swapped +} + +export async function main(): Promise { + const { dependencyName, from } = parseSwapArgs(process.argv.slice(2)) + const name = normalizeSnapshotDependencyName(dependencyName) + if (!name) { + throw new Error(`Unknown snapshot dependency: ${dependencyName}`) + } + const dependency: SnapshotDependency = SNAPSHOT_DEPENDENCIES[name] + const to = await readPinnedVersion(dependency) + const snapshots = dependency.versionSnapshots ?? dependency.snapshots + // Swap every file in memory first, so a failure leaves none of them edited. + const swapped = await Promise.all( + snapshots.map(async snapshot => { + const file = path.join(ROOT, snapshot) + try { + const contents = await fs.readFile(file, 'utf8') + return { file, contents: swapVersionInSnapshot(contents, from, to) } + } catch (e) { + throw new Error(`${snapshot}: ${(e as Error).message}`) + } + }), + ) + await Promise.all( + swapped.map(({ contents, file }) => fs.writeFile(file, contents)), + ) + const stale = (await findStaleSnapshotDependencies()).filter( + finding => normalizeSnapshotDependencyName(finding.dependencyName) === name, + ) + if (stale.length) { + throw new Error(snapshotDependencyUpdateHint(stale)) + } + process.stdout.write( + `Moved ${snapshots.length} ${dependency.packageName} snapshots from ${from} to ${to}\n`, + ) +} + +const SCRIPT_META: ScriptMeta = { + describe: + "moves a pinned tool's native snapshots to its pinned version without a native build", + help: `Usage: node scripts/test/swap-snapshot-version.mts --dep --from + + --dep coana, socket-patch or patch + --from the version the snapshots show now + + Run it after bumping the pin. It fails without editing anything when a + snapshot does not show the old version or a row has no room for the new one. + Fall back to pnpm snapshots:update:dep in that case.`, +} + +if (isMainModule(import.meta.url)) { + runMain(main, SCRIPT_META) +} diff --git a/test/unit/scripts/test/swap-snapshot-version.test.mts b/test/unit/scripts/test/swap-snapshot-version.test.mts new file mode 100644 index 000000000..7cfd9a322 --- /dev/null +++ b/test/unit/scripts/test/swap-snapshot-version.test.mts @@ -0,0 +1,68 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + SNAPSHOT_DEPENDENCIES, + readPinnedVersion, +} from '../../../../scripts/test/snapshot-dependencies.mts' +import { + parseSwapArgs, + swapVersionInSnapshot, +} from '../../../../scripts/test/swap-snapshot-version.mts' + +const ROW = ' Default: 15.15.0. ' + +describe('swapVersionInSnapshot', () => { + it('keeps the row width when the version grows or shrinks', () => { + expect(swapVersionInSnapshot(ROW, '15.15.0', '15.15.10')).toBe( + ' Default: 15.15.10. ', + ) + expect(swapVersionInSnapshot(ROW, '15.15.0', '9.0.0')).toBe( + ' Default: 9.0.0. ', + ) + }) + + it('leaves longer versions that start with the old one alone', () => { + expect(() => + swapVersionInSnapshot('Default: 15.15.01. ', '15.15.0', '15.16.0'), + ).toThrow('does not mention 15.15.0') + expect(() => + swapVersionInSnapshot('Default: 15.15.0.1 ', '15.15.0', '15.16.0'), + ).toThrow('does not mention 15.15.0') + }) + + it('refuses a version too long for the row', () => { + expect(() => + swapVersionInSnapshot('Default: 1.0.0.', '1.0.0', '10.0.0'), + ).toThrow('No room in the row') + }) + + it('round-trips the checked-in Coana snapshots', async () => { + const pinned = await readPinnedVersion(SNAPSHOT_DEPENDENCIES.coana) + for (const snapshot of SNAPSHOT_DEPENDENCIES.coana.snapshots) { + const contents = readFileSync(path.join(process.cwd(), snapshot), 'utf8') + const moved = swapVersionInSnapshot(contents, pinned, '99.99.999') + expect(moved).toHaveLength(contents.length) + expect(moved).not.toContain(pinned) + expect(swapVersionInSnapshot(moved, '99.99.999', pinned)).toBe(contents) + } + }) +}) + +describe('parseSwapArgs', () => { + it('reads --dep and --from', () => { + expect(parseSwapArgs(['--dep', 'coana', '--from', '15.15.0'])).toEqual({ + dependencyName: 'coana', + from: '15.15.0', + }) + }) + + it('needs both flags', () => { + expect(() => parseSwapArgs(['--dep', 'coana'])).toThrow('Pass both') + expect(() => parseSwapArgs(['--to', '1.0.0'])).toThrow( + "Unknown option '--to'", + ) + }) +})