Skip to content

Commit d307603

Browse files
authored
Merge pull request #669 from cipherstash/fix/release-train-coupling
fix(cli): direction-aware version skew + release-train version lockstep
2 parents 0ac45e8 + daa25b8 commit d307603

10 files changed

Lines changed: 341 additions & 5 deletions

File tree

.changeset/config.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22
"$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json",
33
"changelog": "@changesets/cli/changelog",
44
"commit": false,
5-
"fixed": [],
5+
"fixed": [
6+
[
7+
"stash",
8+
"@cipherstash/stack",
9+
"@cipherstash/stack-drizzle",
10+
"@cipherstash/stack-supabase",
11+
"@cipherstash/prisma-next",
12+
"@cipherstash/wizard"
13+
]
14+
],
615
"linked": [],
716
"access": "restricted",
817
"baseBranch": "main",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@cipherstash/prisma-next': patch
3+
---
4+
5+
`@cipherstash/prisma-next` now versions in lockstep with the Stack release
6+
train (`stash`, `@cipherstash/stack`, and the other adapters) via a Changesets
7+
`fixed` group — `stash init` installs it pinned by exact version, so the two
8+
must always release together. This moves the package from its previous `0.4.x`
9+
line onto the shared train version; no API changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
Two guards for the release-train version embed (#661 follow-up):
6+
7+
**Direction-aware version skew.** `stash init` now distinguishes an installed
8+
package that is *behind* this CLI release (offered alignment / the pinned
9+
install command, as before) from one that is *newer* than the release expects.
10+
A newer install no longer produces a downgrade command — init prints the exact
11+
`stash` update command instead (release-train lockstep guarantees that version
12+
exists), and when missing packages are about to be installed alongside newer
13+
ones it says the pairing may not match and to update `stash` first. Unreadable
14+
or malformed manifest versions always count as behind (a broken install should
15+
be offered the reinstall fix, never "looks newer, leave it").
16+
17+
**Version lockstep.** The release-train packages (`stash`,
18+
`@cipherstash/stack`, `@cipherstash/stack-drizzle`,
19+
`@cipherstash/stack-supabase`, `@cipherstash/prisma-next`,
20+
`@cipherstash/wizard`) are now a Changesets `fixed` group: a release of any of
21+
them republishes all of them at the same version, so the CLI's embedded
22+
version map can never go stale against the packages it pins (previously a
23+
runtime-package-only release would have left the published CLI embedding —
24+
and recommending — outdated versions). A test now asserts the fixed group
25+
stays exactly equal to the release train.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@cipherstash/wizard': patch
3+
---
4+
5+
`@cipherstash/wizard` now versions in lockstep with the Stack release train
6+
(`stash`, `@cipherstash/stack`, and the adapters) via a Changesets `fixed`
7+
group — the `stash` CLI executes the wizard by exact version, so the two must
8+
always release together. This moves the package from its previous `0.5.x`
9+
line onto the shared train version; no API changes.

packages/cli/src/__tests__/release-train.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { INTEGRATION_ADAPTER_PACKAGES } from '../commands/init/steps/install-dep
66
import { RELEASE_TRAIN_MANIFESTS } from '../release-train.js'
77

88
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
9+
const REPO_ROOT = resolve(CLI_ROOT, '../..')
910

1011
// The growth guard for #661: `RELEASE_TRAIN_MANIFESTS` is the single source
1112
// the build embeds versions from, and `INTEGRATION_ADAPTER_PACKAGES` is what
@@ -46,4 +47,20 @@ describe('release train coverage', () => {
4647
expect((m.version as string).length).toBeGreaterThan(0)
4748
}
4849
})
50+
51+
it('the changesets fixed group is exactly the release train (staleness guard)', () => {
52+
// The staleness half of #661/#669 is release-process config: every train
53+
// package must version in lockstep with `stash`, or a release of the odd
54+
// one out ships while the published CLI still embeds — and pins — its old
55+
// version. Guard the config like the code: group membership must equal
56+
// the train, exactly.
57+
const config = JSON.parse(
58+
readFileSync(resolve(REPO_ROOT, '.changeset/config.json'), 'utf8'),
59+
) as { fixed?: string[][] }
60+
const group = config.fixed?.find((g) => g.includes('stash'))
61+
expect(group, 'a changesets fixed group containing stash').toBeDefined()
62+
expect(new Set(group)).toEqual(
63+
new Set(Object.keys(RELEASE_TRAIN_MANIFESTS)),
64+
)
65+
})
4966
})

packages/cli/src/__tests__/runtime-versions.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3+
compareVersions,
34
expectedVersion,
45
parseEmbeddedVersions,
56
pinnedSpec,
@@ -94,3 +95,51 @@ describe('runtime-versions (explicit version map)', () => {
9495
expect(expectedVersion('nope', versions)).toBeUndefined()
9596
})
9697
})
98+
99+
// Precedence per semver §11, over the shapes this repo actually publishes.
100+
describe('compareVersions', () => {
101+
it('orders numeric cores', () => {
102+
expect(compareVersions('0.19.0', '1.0.0-rc.1')).toBe(-1)
103+
expect(compareVersions('1.1.0', '1.0.9')).toBe(1)
104+
expect(compareVersions('1.0.0', '1.0.0')).toBe(0)
105+
})
106+
107+
it('a release outranks its own prereleases', () => {
108+
expect(compareVersions('1.0.0', '1.0.0-rc.9')).toBe(1)
109+
expect(compareVersions('1.0.0-rc.9', '1.0.0')).toBe(-1)
110+
})
111+
112+
it('orders prerelease identifiers numerically, not lexically', () => {
113+
expect(compareVersions('1.0.0-rc.2', '1.0.0-rc.1')).toBe(1)
114+
// Lexical comparison would get this one wrong ('10' < '2').
115+
expect(compareVersions('1.0.0-rc.10', '1.0.0-rc.2')).toBe(1)
116+
expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.1')).toBe(0)
117+
})
118+
119+
it('treats any malformed version as not comparable (never "ahead")', () => {
120+
// A corrupt manifest version must not partially parse into 1 ("installed
121+
// is newer") — that would suppress the align guidance. The strict shape
122+
// gate rejects every malformed class, not just NaN-able cores:
123+
expect(compareVersions('v1.0.0', '1.0.0')).toBe(0)
124+
expect(compareVersions('1.0.x', '1.0.0')).toBe(0)
125+
expect(compareVersions('garbage', '1.0.0')).toBe(0)
126+
// truncated core — '1.0' padded to 1.0.0 would outrank 1.0.0-rc.2
127+
expect(compareVersions('1.0', '1.0.0-rc.2')).toBe(0)
128+
// extra core segment
129+
expect(compareVersions('1.2.3.4', '1.2.3')).toBe(0)
130+
// empty prerelease — '1.0.0-' as a release would outrank any rc
131+
expect(compareVersions('1.0.0-', '1.0.0-rc.1')).toBe(0)
132+
// parseInt trailing garbage — '9.9.10rc' would parse as core 9.9.10
133+
expect(compareVersions('9.9.10rc', '9.9.9')).toBe(0)
134+
expect(compareVersions('1.0.0beta', '1.0.0-rc.1')).toBe(0)
135+
// build metadata — '2+sha' as an identifier would mis-order vs 'rc.10'
136+
expect(compareVersions('1.0.0-rc.2+sha.abc', '1.0.0-rc.10')).toBe(0)
137+
// empty prerelease identifier
138+
expect(compareVersions('1.0.0-rc..1', '1.0.0-rc.1')).toBe(0)
139+
})
140+
141+
it('numeric identifiers sort below alphanumeric; shorter below longer', () => {
142+
expect(compareVersions('1.0.0-1', '1.0.0-alpha')).toBe(-1)
143+
expect(compareVersions('1.0.0-rc', '1.0.0-rc.1')).toBe(-1)
144+
})
145+
})

packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ vi.mock('../../utils.js', () => ({
1313
...(dev.length ? [`npm install --save-dev ${dev.join(' ')}`] : []),
1414
],
1515
),
16+
devInstallCommand: vi.fn(
17+
(_pm: string, pkg: string) => `npm install -D ${pkg}`,
18+
),
1619
detectPackageManager: vi.fn(() => 'npm'),
1720
}))
1821
// Pin map: pretend this CLI release was built alongside these versions, so
@@ -26,7 +29,10 @@ const FIXTURE_VERSIONS: Record<string, string> = vi.hoisted(() => ({
2629
'@cipherstash/stack': '9.9.9-test.1',
2730
'@cipherstash/stack-supabase': '9.9.9-test.1',
2831
}))
29-
vi.mock('../../../../runtime-versions.js', () => ({
32+
vi.mock('../../../../runtime-versions.js', async (importOriginal) => ({
33+
// Keep the real pure helpers (compareVersions, parseEmbeddedVersions);
34+
// override only the release map and the map-reading functions.
35+
...(await importOriginal<typeof import('../../../../runtime-versions.js')>()),
3036
RUNTIME_PACKAGE_VERSIONS: FIXTURE_VERSIONS,
3137
expectedVersion: (
3238
pkg: string,
@@ -216,6 +222,58 @@ describe('installDepsStep', () => {
216222
expect(result.cliInstalled).toBe(true)
217223
})
218224

225+
it('a NEWER install gets an update-stash warning, never a downgrade command', async () => {
226+
vi.mocked(isInteractive).mockReturnValue(false)
227+
present('@cipherstash/stack', 'stash')
228+
resolvedVersions({
229+
'@cipherstash/stack': '9.9.10',
230+
stash: FIXTURE_VERSIONS.stash,
231+
})
232+
233+
const result = await installDepsStep.run(baseState, provider)
234+
235+
expect(p.log.warn).toHaveBeenCalledWith(
236+
expect.stringContaining(
237+
'@cipherstash/stack: installed 9.9.10 is newer than this release of stash expects (9.9.9-test.1)',
238+
),
239+
)
240+
// Lockstep means stash@<installed> must exist — the warning prints the
241+
// exact update command instead of an uncommanded "matching release".
242+
expect(p.log.warn).toHaveBeenCalledWith(
243+
expect.stringContaining('npm install -D stash@9.9.10'),
244+
)
245+
// No align/downgrade guidance, no mutation, clean success.
246+
expect(p.note).not.toHaveBeenCalled()
247+
expect(execSyncMock).not.toHaveBeenCalled()
248+
expect(result.stackInstalled).toBe(true)
249+
})
250+
251+
it('mixed ahead + missing: warns that fresh installs pin to THIS release and may mismatch', async () => {
252+
vi.mocked(isInteractive).mockReturnValue(false)
253+
// stack is installed and NEWER; the supabase adapter is missing — init
254+
// will install the adapter at this CLI's older embed, so it must say the
255+
// pairing may not match rather than silently manufacturing a cross-train
256+
// mismatch under a "likely fine" banner.
257+
present('@cipherstash/stack', 'stash')
258+
resolvedVersions({
259+
'@cipherstash/stack': '9.9.10',
260+
stash: FIXTURE_VERSIONS.stash,
261+
})
262+
263+
await installDepsStep.run(baseState, supabaseProvider)
264+
265+
expect(p.log.warn).toHaveBeenCalledWith(
266+
expect.stringContaining(
267+
"@cipherstash/stack-supabase will be installed at THIS release's versions",
268+
),
269+
)
270+
expect(p.log.warn).toHaveBeenCalledWith(
271+
expect.stringContaining('npm install -D stash@9.9.10'),
272+
)
273+
// The missing adapter still installs (non-interactive default).
274+
expect(execSyncMock).toHaveBeenCalled()
275+
})
276+
219277
it('reports an unreadable manifest as skew, not as a matching install', async () => {
220278
vi.mocked(isInteractive).mockReturnValue(false)
221279
present('@cipherstash/stack', 'stash')
@@ -257,6 +315,24 @@ describe('versionSkew', () => {
257315
pkg: '@cipherstash/stack',
258316
installed: '0.19.0',
259317
expected: '9.9.9-test.1',
318+
direction: 'behind',
319+
},
320+
])
321+
})
322+
323+
it('classifies a newer-than-expected install as ahead', () => {
324+
present('@cipherstash/stack')
325+
resolvedVersions({ '@cipherstash/stack': '9.9.10' })
326+
expect(
327+
versionSkew(['@cipherstash/stack'], {
328+
'@cipherstash/stack': '9.9.9-test.1',
329+
}),
330+
).toEqual([
331+
{
332+
pkg: '@cipherstash/stack',
333+
installed: '9.9.10',
334+
expected: '9.9.9-test.1',
335+
direction: 'ahead',
260336
},
261337
])
262338
})
@@ -274,6 +350,23 @@ describe('versionSkew', () => {
274350
expect(versionSkew(['@no-map/package'], {})).toEqual([])
275351
})
276352

353+
it('classifies a garbage installed version as behind (align guidance), not ahead', () => {
354+
present('@cipherstash/stack')
355+
resolvedVersions({ '@cipherstash/stack': 'not-a-version' })
356+
expect(
357+
versionSkew(['@cipherstash/stack'], {
358+
'@cipherstash/stack': '9.9.9-test.1',
359+
}),
360+
).toEqual([
361+
{
362+
pkg: '@cipherstash/stack',
363+
installed: 'not-a-version',
364+
expected: '9.9.9-test.1',
365+
direction: 'behind',
366+
},
367+
])
368+
})
369+
277370
it('flags an installed package with an unreadable manifest', () => {
278371
present('@cipherstash/stack')
279372
resolvedVersions({ '@cipherstash/stack': undefined })
@@ -286,6 +379,8 @@ describe('versionSkew', () => {
286379
pkg: '@cipherstash/stack',
287380
installed: 'unknown (unreadable package.json)',
288381
expected: '9.9.9-test.1',
382+
// Unreadable = broken install → offer the (re)install fix.
383+
direction: 'behind',
289384
},
290385
])
291386
})

packages/cli/src/commands/init/steps/install-deps.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { execSync } from 'node:child_process'
22
import * as p from '@clack/prompts'
33
import { isInteractive } from '../../../config/tty.js'
44
import {
5+
compareVersions,
56
expectedVersion,
67
pinnedSpec,
78
RUNTIME_PACKAGE_VERSIONS,
@@ -11,6 +12,7 @@ import { CancelledError } from '../types.js'
1112
import {
1213
combinedInstallCommands,
1314
detectPackageManager,
15+
devInstallCommand,
1416
installedVersion,
1517
isPackageInstalled,
1618
} from '../utils.js'
@@ -49,6 +51,10 @@ export type VersionSkewEntry = {
4951
pkg: string
5052
installed: string
5153
expected: string
54+
/** `behind`: older than this release (or unreadable) — offer alignment.
55+
* `ahead`: NEWER than this release expects — the install is likely fine
56+
* and the fix is updating stash, never downgrading the package. */
57+
direction: 'behind' | 'ahead'
5258
}
5359

5460
/**
@@ -71,7 +77,15 @@ export function versionSkew(
7177
if (!expected) continue
7278
if (!isPackageInstalled(pkg)) continue
7379
const installed = installedVersion(pkg) ?? UNREADABLE_VERSION
74-
if (installed !== expected) skewed.push({ pkg, installed, expected })
80+
if (installed === expected) continue
81+
// Unreadable manifests are treated as `behind`: a broken install should
82+
// be offered the (re)install fix, not a stash upgrade.
83+
const direction =
84+
installed !== UNREADABLE_VERSION &&
85+
compareVersions(installed, expected) > 0
86+
? ('ahead' as const)
87+
: ('behind' as const)
88+
skewed.push({ pkg, installed, expected, direction })
7589
}
7690
return skewed
7791
}
@@ -87,6 +101,17 @@ function skewLines(skewed: readonly VersionSkewEntry[]): string {
87101
.join('\n ')
88102
}
89103

104+
/** Render the newer-than-expected lines: the fix is updating stash, never
105+
* downgrading a runtime package past releases the project already uses. */
106+
function aheadLines(ahead: readonly VersionSkewEntry[]): string {
107+
return ahead
108+
.map(
109+
({ pkg, installed, expected }) =>
110+
`${pkg}: installed ${installed} is newer than this release of stash expects (${expected})`,
111+
)
112+
.join('\n ')
113+
}
114+
90115
/** Split pinned install specs into (prod, dev) lists — `stash` is a dev
91116
* dependency by init's own convention; everything else is prod. */
92117
function splitProdDev(packages: readonly string[]): {
@@ -151,7 +176,13 @@ export const installDepsStep: InitStep = {
151176
// failure, or early return can skip it (#661). Every path below inherits
152177
// this warning.
153178
const pm = detectPackageManager()
154-
const skewed = versionSkew(allPackages)
179+
const allSkew = versionSkew(allPackages)
180+
// Direction matters (#666 review): only packages BEHIND this release get
181+
// the align treatment. A package AHEAD of this release means the CLI is
182+
// the stale side — advising a downgrade would walk the project back past
183+
// releases it already depends on.
184+
const skewed = allSkew.filter(({ direction }) => direction === 'behind')
185+
const ahead = allSkew.filter(({ direction }) => direction === 'ahead')
155186
const alignSplit = splitProdDev(skewed.map(({ pkg }) => pkg))
156187
const alignCommands = combinedInstallCommands(
157188
pm,
@@ -169,6 +200,29 @@ export const installDepsStep: InitStep = {
169200
if (!cliPresent) missing.push(CLI_PACKAGE)
170201
const missingSplit = splitProdDev(missing)
171202

203+
if (ahead.length > 0) {
204+
// Every release-train package versions in lockstep (the changesets
205+
// `fixed` group), so a train package strictly ahead of this CLI's embed
206+
// implies a stash release exists at that exact version — print the
207+
// command instead of leaving the user to research "the matching
208+
// release". Highest ahead version wins when several differ.
209+
const target = ahead
210+
.map(({ installed }) => installed)
211+
.reduce((max, v) => (compareVersions(v, max) > 0 ? v : max))
212+
const updateCmd = devInstallCommand(pm, `stash@${target}`)
213+
// Installing MISSING packages now would pin them to this CLI's older
214+
// embed, pairing them with the newer installed packages — a combination
215+
// no lockstep release ever shipped. Say so instead of silently
216+
// manufacturing the mismatch.
217+
const missingNote =
218+
missing.length > 0
219+
? `\nNote: ${missing.join(', ')} will be installed at THIS release's versions, which may not match the newer packages above — for a consistent set, update stash first and re-run init:\n ${updateCmd}`
220+
: `\nUpdate with:\n ${updateCmd}\nthen re-run init.`
221+
p.log.warn(
222+
`Installed versions are newer than this release of stash:\n ${aheadLines(ahead)}\nYour installs are likely fine — update the stash CLI to the matching release instead of downgrading.${missingNote}`,
223+
)
224+
}
225+
172226
// Interactively, skewed packages can be aligned in the same install run.
173227
// Non-interactive runs never mutate an existing install: agents/CI get
174228
// the warning + exact commands above and keep going.

0 commit comments

Comments
 (0)