Skip to content

Commit c32b5bd

Browse files
auxesisclaude
andcommitted
feat(release): move the skill version pins in the version step
The skills ship inside the stash tarball, and nothing rewrites the version pins in them. People moved them by hand in each Version Packages PR: #928 and #938 did, and #1020 did not, because no CI ran on it. main then failed release-train.test.ts until #1029. scripts/sync-skill-pins.mjs now runs from the root `version` script, right after `changeset version`. It rewrites every exact pin of a release-train package in skills/ to the stable version in the tree, so each Version Packages PR carries the pins with the versions they name. The packages are the changesets `fixed` group that holds `stash`, which a test holds equal to the CLI's RELEASE_TRAIN_MANIFESTS. Run on main's stale pins, the script makes exactly the change in #1029. Refs: CIP-4285 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
1 parent 4878718 commit c32b5bd

6 files changed

Lines changed: 342 additions & 3 deletions

File tree

‎AGENTS.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,13 @@ Every command and flag named in `skills/stash-cli/SKILL.md` must resolve against
767767
manifest (the deprecated `db install` / `db upgrade` / `db status` aliases excepted —
768768
they're intentionally absent from the registry).
769769

770+
**The version step moves the release-train pins.** `scripts/sync-skill-pins.mjs`
771+
runs from the root `version` script after `changeset version`. It rewrites every
772+
exact pin of a release-train package in `skills/`, such as
773+
`npx --package=stash@X.Y.Z` and `npm:@cipherstash/stack@X.Y.Z/wasm-inline`, to the
774+
stable version in the tree, so the Version Packages PR carries them. Name an older
775+
release in prose without the `name@X.Y.Z` form, or the script moves it too.
776+
770777
Skills must not contain Linear issue IDs; they're public. GitHub issue numbers are fine.
771778

772779
## Supply Chain Security

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs",
3636
"lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
3737
"release:gate": "node scripts/release-gate.mjs",
38-
"version": "changeset version && node scripts/sync-lockstep-versions.mjs",
38+
"version": "changeset version && node scripts/sync-skill-pins.mjs && node scripts/sync-lockstep-versions.mjs",
3939
"release": "pnpm run build && changeset publish",
4040
"test": "turbo test --filter './languages/typescript/packages/**' --filter './packages/**'",
4141
"test:e2e": "turbo run test:e2e",

‎packages/eql/docs/development/releasing.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ at one commit.
3939
The root `version` script is the mechanism:
4040

4141
```
42-
pnpm run version == changeset version && node scripts/sync-lockstep-versions.mjs
42+
pnpm run version == changeset version && node scripts/sync-skill-pins.mjs && node scripts/sync-lockstep-versions.mjs
4343
```
4444

45+
`scripts/sync-skill-pins.mjs` moves the `stash` release-train pins in `skills/`
46+
and does not touch EQL.
47+
4548
1. `changeset version` consumes the pending `.changeset/*.md` files and bumps
4649
`packages/eql/package.json` to the next `V`.
4750
2. `scripts/sync-lockstep-versions.mjs` reads that `V` and propagates it:
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { spawnSync } from 'node:child_process'
2+
import {
3+
copyFileSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readFileSync,
7+
realpathSync,
8+
rmSync,
9+
writeFileSync,
10+
} from 'node:fs'
11+
import { tmpdir } from 'node:os'
12+
import { dirname, join } from 'node:path'
13+
import { afterEach, describe, expect, it } from 'vitest'
14+
import { RELEASE_TRAIN_MANIFESTS } from '../../languages/typescript/packages/cli/src/release-train.ts'
15+
import {
16+
releaseTrain,
17+
stableVersion,
18+
syncPins,
19+
syncSkillPins,
20+
trainVersions,
21+
} from '../sync-skill-pins.mjs'
22+
import { REPO_ROOT } from './lib/repo-root.mjs'
23+
24+
/**
25+
* The version step moves the skill pins, so a Version Packages PR cannot leave
26+
* them behind. #1020 did, because no CI ran on it, and `main` then failed
27+
* `release-train.test.ts` until #1029 moved them by hand.
28+
*/
29+
30+
const CONFIG = JSON.parse(
31+
readFileSync(join(REPO_ROOT, '.changeset/config.json'), 'utf8'),
32+
)
33+
const VERSIONS = new Map([
34+
['stash', '1.2.1'],
35+
['@cipherstash/stack', '1.2.1'],
36+
])
37+
38+
describe('the release train', () => {
39+
it('is the fixed group that holds stash, and the CLI release train', () => {
40+
// Two lists of one set: changesets moves these together, and
41+
// release-train.test.ts checks their pins.
42+
expect(releaseTrain(CONFIG).sort()).toEqual(
43+
Object.keys(RELEASE_TRAIN_MANIFESTS).sort(),
44+
)
45+
})
46+
47+
it('refuses a config with no group holding stash', () => {
48+
expect(() => releaseTrain({ fixed: [['@cipherstash/auth']] })).toThrow(
49+
/holds `stash`/,
50+
)
51+
})
52+
53+
it('reads each version from the manifest the CLI embeds', () => {
54+
const versions = trainVersions(REPO_ROOT, releaseTrain(CONFIG))
55+
for (const [name, rel] of Object.entries(RELEASE_TRAIN_MANIFESTS)) {
56+
const manifest = JSON.parse(
57+
readFileSync(
58+
join(REPO_ROOT, 'languages/typescript/packages/cli', rel),
59+
'utf8',
60+
),
61+
)
62+
expect(versions.get(name), name).toBe(manifest.version)
63+
}
64+
})
65+
66+
it('refuses a train package with no manifest', () => {
67+
expect(() =>
68+
trainVersions(REPO_ROOT, ['stash', '@cipherstash/no-such-package']),
69+
).toThrow(/@cipherstash\/no-such-package/)
70+
})
71+
})
72+
73+
describe('the pins', () => {
74+
it('move the three forms the skills use', () => {
75+
const before = [
76+
"npx --package=stash@1.2.0 stash eql install --database-url 'postgres://...'",
77+
"} from 'npm:@cipherstash/stack@1.2.0/wasm-inline'",
78+
'"@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@1.2.0/wasm-inline"',
79+
].join('\n')
80+
const { text, changes } = syncPins(before, VERSIONS)
81+
expect(text).toBe(
82+
[
83+
"npx --package=stash@1.2.1 stash eql install --database-url 'postgres://...'",
84+
"} from 'npm:@cipherstash/stack@1.2.1/wasm-inline'",
85+
'"@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@1.2.1/wasm-inline"',
86+
].join('\n'),
87+
)
88+
expect(changes).toHaveLength(3)
89+
})
90+
91+
it('pin the stable version during a prerelease', () => {
92+
expect(stableVersion('1.3.0-rc.0')).toBe('1.3.0')
93+
const { text } = syncPins(
94+
'npx --package=stash@1.2.1 stash',
95+
new Map([['stash', '1.3.0-rc.0']]),
96+
)
97+
expect(text).toBe('npx --package=stash@1.3.0 stash')
98+
})
99+
100+
it('leave everything that is not a release-train pin alone', () => {
101+
const text = [
102+
'@cipherstash/eql@3.0.6',
103+
'@cipherstash/protect-ffi@0.33.0',
104+
'"stash": "^1.0.0"',
105+
'cipherstash@1.0.0',
106+
'mystash@1.0.0',
107+
'stash@latest',
108+
].join('\n')
109+
expect(syncPins(text, VERSIONS)).toEqual({ text, changes: [] })
110+
})
111+
112+
it('change nothing that is already current', () => {
113+
const text = 'npx --package=stash@1.2.1 stash eql install'
114+
expect(syncPins(text, VERSIONS)).toEqual({ text, changes: [] })
115+
})
116+
117+
it('in this tree already name the release-train versions', () => {
118+
// What the next Version Packages PR starts from. A failure here names the
119+
// file: run `node scripts/sync-skill-pins.mjs`.
120+
expect(syncSkillPins({ root: REPO_ROOT, write: false })).toEqual([])
121+
})
122+
})
123+
124+
/** The real script over a throwaway tree with one stale skill. */
125+
describe('the script', () => {
126+
let root
127+
afterEach(() => root && rmSync(root, { recursive: true, force: true }))
128+
129+
function fixture() {
130+
root = realpathSync(mkdtempSync(join(tmpdir(), 'skill-pins-')))
131+
const write = (rel, text) => {
132+
mkdirSync(dirname(join(root, rel)), { recursive: true })
133+
writeFileSync(join(root, rel), text)
134+
}
135+
write(
136+
'.changeset/config.json',
137+
JSON.stringify({ fixed: [['stash', '@cipherstash/stack']] }),
138+
)
139+
write(
140+
'languages/typescript/packages/cli/package.json',
141+
JSON.stringify({ name: 'stash', version: '9.9.9' }),
142+
)
143+
write(
144+
'languages/typescript/packages/stack/package.json',
145+
JSON.stringify({ name: '@cipherstash/stack', version: '9.9.9' }),
146+
)
147+
write(
148+
'skills/stash-cli/SKILL.md',
149+
'npx --package=stash@9.9.8 stash eql install\n',
150+
)
151+
write('skills/other/SKILL.md', 'nothing pinned here\n')
152+
mkdirSync(join(root, 'scripts'))
153+
copyFileSync(
154+
join(REPO_ROOT, 'scripts/sync-skill-pins.mjs'),
155+
join(root, 'scripts/sync-skill-pins.mjs'),
156+
)
157+
return () =>
158+
spawnSync(process.execPath, [join(root, 'scripts/sync-skill-pins.mjs')], {
159+
cwd: root,
160+
encoding: 'utf8',
161+
})
162+
}
163+
164+
it('moves a stale pin, and a second run changes nothing', () => {
165+
const run = fixture()
166+
const first = run()
167+
expect(first.status).toBe(0)
168+
expect(first.stdout).toContain(
169+
'skills/stash-cli/SKILL.md: stash@9.9.8 -> stash@9.9.9',
170+
)
171+
expect(readFileSync(join(root, 'skills/stash-cli/SKILL.md'), 'utf8')).toBe(
172+
'npx --package=stash@9.9.9 stash eql install\n',
173+
)
174+
175+
const second = run()
176+
expect(second.status).toBe(0)
177+
expect(second.stdout).toContain('already name the release-train versions')
178+
})
179+
})
180+
181+
describe('the root version script moves the pins', () => {
182+
const version = JSON.parse(
183+
readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'),
184+
).scripts.version
185+
186+
it('runs the pin sync after `changeset version`, which sets the versions', () => {
187+
expect(version).toContain('node scripts/sync-skill-pins.mjs')
188+
expect(version.indexOf('changeset version')).toBeLessThan(
189+
version.indexOf('sync-skill-pins.mjs'),
190+
)
191+
// `&&`, so a failed sync stops the release rather than shipping stale pins.
192+
expect(version).toMatch(
193+
/changeset version && .*node scripts\/sync-skill-pins\.mjs &&/,
194+
)
195+
})
196+
})

‎scripts/__tests__/workflow-mise-setup.test.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { readWorkflow, workflowFiles } from './lib/workflows.mjs'
1818
* is three hops deep and entirely invisible in the workflow file:
1919
*
2020
* version: pnpm run version (release.yml)
21-
* -> "changeset version && node scripts/sync-lockstep-versions.mjs"
21+
* -> "changeset version && … && node scripts/sync-lockstep-versions.mjs"
2222
* (root package.json)
2323
* -> execFileSync('mise', ['run', 'release:prepare_bindings_assets'…])
2424
* (that script)

‎scripts/sync-skill-pins.mjs‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Move the release-train version pins in `skills/` to the versions in the tree.
3+
*
4+
* `skills/` ships inside the `stash` tarball verbatim, and no build step
5+
* rewrites a version inside it, so a pin such as `npx --package=stash@1.2.0`
6+
* keeps telling customers to install the previous release.
7+
* `release-train.test.ts` fails a tree whose `stash-cli` pin is not the
8+
* `stash` version. People moved the pins by hand in each Version Packages PR:
9+
* #928 and #938 did, and #1020 did not, because no CI ran on it.
10+
*
11+
* This runs from the root `version` script, right after `changeset version`,
12+
* so the Version Packages PR carries the pins with the versions they name.
13+
*
14+
* The packages are the changesets `fixed` group that holds `stash`: the ones
15+
* `changeset version` moves together. A test holds that group to the CLI's
16+
* `RELEASE_TRAIN_MANIFESTS`, which is the set `release-train.test.ts` checks.
17+
* A pin takes the STABLE version, because a prerelease must not be pinned in a
18+
* customer's repo; that is the test's rule too.
19+
*/
20+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
21+
import { join, relative, resolve, sep } from 'node:path'
22+
import process from 'node:process'
23+
import { fileURLToPath } from 'node:url'
24+
25+
const REPO_ROOT = resolve(import.meta.dirname, '..')
26+
27+
export const SKILLS_DIR = 'skills'
28+
export const PACKAGES_DIR = 'languages/typescript/packages'
29+
30+
/**
31+
* `release-train.test.ts`'s pin form: an exact `name@X.Y.Z[-pre]`, optionally
32+
* `npm:`-prefixed. A range such as `^1.0.0` is not a pin. The lookbehind stops
33+
* `stash` matching inside a longer name.
34+
*/
35+
const PIN =
36+
/(?<![\w-])((?:npm:)?)(stash|@cipherstash\/[a-z0-9-]+)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g
37+
38+
export const stableVersion = (version) => version.split('-')[0]
39+
40+
/** The names in the changesets `fixed` group that holds `stash`. */
41+
export function releaseTrain(changesetConfig) {
42+
const group = (changesetConfig.fixed ?? []).find((names) =>
43+
names.includes('stash'),
44+
)
45+
if (!group) {
46+
throw new Error(
47+
'no `fixed` group in .changeset/config.json holds `stash`, so there is no ' +
48+
'release train to pin the skills to',
49+
)
50+
}
51+
return [...group]
52+
}
53+
54+
/** name -> version for each `names` entry, from the package manifests. */
55+
export function trainVersions(root, names) {
56+
const found = new Map()
57+
const dir = join(root, PACKAGES_DIR)
58+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
59+
if (!entry.isDirectory()) continue
60+
let manifest
61+
try {
62+
manifest = JSON.parse(
63+
readFileSync(join(dir, entry.name, 'package.json'), 'utf8'),
64+
)
65+
} catch (err) {
66+
if (err.code === 'ENOENT') continue
67+
throw err
68+
}
69+
if (names.includes(manifest.name))
70+
found.set(manifest.name, manifest.version)
71+
}
72+
const missing = names.filter((name) => !found.has(name))
73+
if (missing.length > 0) {
74+
throw new Error(
75+
`no manifest under ${PACKAGES_DIR}/ names ${missing.join(', ')}, so their skill ` +
76+
'pins cannot be moved',
77+
)
78+
}
79+
return found
80+
}
81+
82+
/** Rewrite each train pin in `text` to its stable version. */
83+
export function syncPins(text, versions) {
84+
const changes = []
85+
const out = text.replace(PIN, (whole, prefix, pkg, pinned) => {
86+
if (!versions.has(pkg)) return whole
87+
const to = stableVersion(versions.get(pkg))
88+
if (pinned === to) return whole
89+
changes.push({ pkg, from: pinned, to })
90+
return `${prefix}${pkg}@${to}`
91+
})
92+
return { text: out, changes }
93+
}
94+
95+
function markdownFiles(dir) {
96+
return readdirSync(dir, { withFileTypes: true, recursive: true })
97+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
98+
.map((entry) => join(entry.parentPath, entry.name))
99+
.sort()
100+
}
101+
102+
/** Every changed file, repo-relative, with its changes. Writes unless `write` is false. */
103+
export function syncSkillPins({ root = REPO_ROOT, write = true } = {}) {
104+
const config = JSON.parse(
105+
readFileSync(join(root, '.changeset/config.json'), 'utf8'),
106+
)
107+
const versions = trainVersions(root, releaseTrain(config))
108+
const changed = []
109+
for (const file of markdownFiles(join(root, SKILLS_DIR))) {
110+
const before = readFileSync(file, 'utf8')
111+
const { text, changes } = syncPins(before, versions)
112+
if (changes.length === 0) continue
113+
if (write) writeFileSync(file, text)
114+
changed.push({ file: relative(root, file).split(sep).join('/'), changes })
115+
}
116+
return changed
117+
}
118+
119+
function main() {
120+
const changed = syncSkillPins()
121+
for (const { file, changes } of changed) {
122+
for (const { pkg, from, to } of changes) {
123+
console.log(`${file}: ${pkg}@${from} -> ${pkg}@${to}`)
124+
}
125+
}
126+
console.log(
127+
changed.length === 0
128+
? 'skill pins already name the release-train versions'
129+
: `moved the release-train pins in ${changed.length} skill file(s)`,
130+
)
131+
}
132+
133+
if (process.argv[1] === fileURLToPath(import.meta.url)) main()

0 commit comments

Comments
 (0)