Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions .claude/skills/bump-coana/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -130,7 +142,8 @@ Replace `CURRENT_VERSION` and `COANA_VERSION` with actual values.
- Branch: `coana-<VERSION>` 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.

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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/
Expand Down
24 changes: 14 additions & 10 deletions scripts/test/snapshot-dependencies.mts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ export interface StaleSnapshotDependency {
readonly snapshots: readonly string[]
}

export async function readPinnedVersion(
dependency: SnapshotDependency,
): Promise<string> {
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[]
> {
Expand All @@ -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 => ({
Expand Down
134 changes: 134 additions & 0 deletions scripts/test/swap-snapshot-version.mts
Original file line number Diff line number Diff line change
@@ -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 <name> and --from <old version>')
}
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(
`(?<![\\w.])${escapeRegExp(from)}(?!\\w|\\.\\d)(\\S*)( *)`,
'g',
)
let found = false
const swapped = contents.replace(
pattern,
(_match, tail: string, padding: string) => {
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<void> {
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 <name> --from <old version>

--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 <name> in that case.`,
}

if (isMainModule(import.meta.url)) {
runMain(main, SCRIPT_META)
}
68 changes: 68 additions & 0 deletions test/unit/scripts/test/swap-snapshot-version.test.mts
Original file line number Diff line number Diff line change
@@ -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 = '<tspan> Default: 15.15.0. </tspan>'

describe('swapVersionInSnapshot', () => {
it('keeps the row width when the version grows or shrinks', () => {
expect(swapVersionInSnapshot(ROW, '15.15.0', '15.15.10')).toBe(
'<tspan> Default: 15.15.10. </tspan>',
)
expect(swapVersionInSnapshot(ROW, '15.15.0', '9.0.0')).toBe(
'<tspan> Default: 9.0.0. </tspan>',
)
})

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.</tspan>', '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'",
)
})
})
Loading