Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
"$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"fixed": [
[
"stash",
"@cipherstash/stack",
"@cipherstash/stack-drizzle",
"@cipherstash/stack-supabase",
"@cipherstash/prisma-next",
"@cipherstash/wizard"
]
],
"linked": [],
"access": "restricted",
"baseBranch": "main",
Expand Down
9 changes: 9 additions & 0 deletions .changeset/prisma-next-joins-release-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cipherstash/prisma-next': patch
---

`@cipherstash/prisma-next` now versions in lockstep with the Stack release
train (`stash`, `@cipherstash/stack`, and the other adapters) via a Changesets
`fixed` group — `stash init` installs it pinned by exact version, so the two
must always release together. This moves the package from its previous `0.4.x`
line onto the shared train version; no API changes.
25 changes: 25 additions & 0 deletions .changeset/release-train-coupling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'stash': patch
---

Two guards for the release-train version embed (#661 follow-up):

**Direction-aware version skew.** `stash init` now distinguishes an installed
package that is *behind* this CLI release (offered alignment / the pinned
install command, as before) from one that is *newer* than the release expects.
A newer install no longer produces a downgrade command — init prints the exact
`stash` update command instead (release-train lockstep guarantees that version
exists), and when missing packages are about to be installed alongside newer
ones it says the pairing may not match and to update `stash` first. Unreadable
or malformed manifest versions always count as behind (a broken install should
be offered the reinstall fix, never "looks newer, leave it").

**Version lockstep.** The release-train packages (`stash`,
`@cipherstash/stack`, `@cipherstash/stack-drizzle`,
`@cipherstash/stack-supabase`, `@cipherstash/prisma-next`,
`@cipherstash/wizard`) are now a Changesets `fixed` group: a release of any of
them republishes all of them at the same version, so the CLI's embedded
version map can never go stale against the packages it pins (previously a
runtime-package-only release would have left the published CLI embedding —
and recommending — outdated versions). A test now asserts the fixed group
stays exactly equal to the release train.
9 changes: 9 additions & 0 deletions .changeset/wizard-joins-release-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cipherstash/wizard': patch
---

`@cipherstash/wizard` now versions in lockstep with the Stack release train
(`stash`, `@cipherstash/stack`, and the adapters) via a Changesets `fixed`
group — the `stash` CLI executes the wizard by exact version, so the two must
always release together. This moves the package from its previous `0.5.x`
line onto the shared train version; no API changes.
17 changes: 17 additions & 0 deletions packages/cli/src/__tests__/release-train.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { INTEGRATION_ADAPTER_PACKAGES } from '../commands/init/steps/install-dep
import { RELEASE_TRAIN_MANIFESTS } from '../release-train.js'

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

// The growth guard for #661: `RELEASE_TRAIN_MANIFESTS` is the single source
// the build embeds versions from, and `INTEGRATION_ADAPTER_PACKAGES` is what
Expand Down Expand Up @@ -46,4 +47,20 @@ describe('release train coverage', () => {
expect((m.version as string).length).toBeGreaterThan(0)
}
})

it('the changesets fixed group is exactly the release train (staleness guard)', () => {
// The staleness half of #661/#669 is release-process config: every train
// package must version in lockstep with `stash`, or a release of the odd
// one out ships while the published CLI still embeds — and pins — its old
// version. Guard the config like the code: group membership must equal
// the train, exactly.
const config = JSON.parse(
readFileSync(resolve(REPO_ROOT, '.changeset/config.json'), 'utf8'),
) as { fixed?: string[][] }
const group = config.fixed?.find((g) => g.includes('stash'))
expect(group, 'a changesets fixed group containing stash').toBeDefined()
expect(new Set(group)).toEqual(
new Set(Object.keys(RELEASE_TRAIN_MANIFESTS)),
)
})
})
49 changes: 49 additions & 0 deletions packages/cli/src/__tests__/runtime-versions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
compareVersions,
expectedVersion,
parseEmbeddedVersions,
pinnedSpec,
Expand Down Expand Up @@ -94,3 +95,51 @@ describe('runtime-versions (explicit version map)', () => {
expect(expectedVersion('nope', versions)).toBeUndefined()
})
})

// Precedence per semver §11, over the shapes this repo actually publishes.
describe('compareVersions', () => {
it('orders numeric cores', () => {
expect(compareVersions('0.19.0', '1.0.0-rc.1')).toBe(-1)
expect(compareVersions('1.1.0', '1.0.9')).toBe(1)
expect(compareVersions('1.0.0', '1.0.0')).toBe(0)
})

it('a release outranks its own prereleases', () => {
expect(compareVersions('1.0.0', '1.0.0-rc.9')).toBe(1)
expect(compareVersions('1.0.0-rc.9', '1.0.0')).toBe(-1)
})

it('orders prerelease identifiers numerically, not lexically', () => {
expect(compareVersions('1.0.0-rc.2', '1.0.0-rc.1')).toBe(1)
// Lexical comparison would get this one wrong ('10' < '2').
expect(compareVersions('1.0.0-rc.10', '1.0.0-rc.2')).toBe(1)
expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.1')).toBe(0)
})

it('treats any malformed version as not comparable (never "ahead")', () => {
// A corrupt manifest version must not partially parse into 1 ("installed
// is newer") — that would suppress the align guidance. The strict shape
// gate rejects every malformed class, not just NaN-able cores:
expect(compareVersions('v1.0.0', '1.0.0')).toBe(0)
expect(compareVersions('1.0.x', '1.0.0')).toBe(0)
expect(compareVersions('garbage', '1.0.0')).toBe(0)
// truncated core — '1.0' padded to 1.0.0 would outrank 1.0.0-rc.2
expect(compareVersions('1.0', '1.0.0-rc.2')).toBe(0)
// extra core segment
expect(compareVersions('1.2.3.4', '1.2.3')).toBe(0)
// empty prerelease — '1.0.0-' as a release would outrank any rc
expect(compareVersions('1.0.0-', '1.0.0-rc.1')).toBe(0)
// parseInt trailing garbage — '9.9.10rc' would parse as core 9.9.10
expect(compareVersions('9.9.10rc', '9.9.9')).toBe(0)
expect(compareVersions('1.0.0beta', '1.0.0-rc.1')).toBe(0)
// build metadata — '2+sha' as an identifier would mis-order vs 'rc.10'
expect(compareVersions('1.0.0-rc.2+sha.abc', '1.0.0-rc.10')).toBe(0)
// empty prerelease identifier
expect(compareVersions('1.0.0-rc..1', '1.0.0-rc.1')).toBe(0)
})

it('numeric identifiers sort below alphanumeric; shorter below longer', () => {
expect(compareVersions('1.0.0-1', '1.0.0-alpha')).toBe(-1)
expect(compareVersions('1.0.0-rc', '1.0.0-rc.1')).toBe(-1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ vi.mock('../../utils.js', () => ({
...(dev.length ? [`npm install --save-dev ${dev.join(' ')}`] : []),
],
),
devInstallCommand: vi.fn(
(_pm: string, pkg: string) => `npm install -D ${pkg}`,
),
detectPackageManager: vi.fn(() => 'npm'),
}))
// Pin map: pretend this CLI release was built alongside these versions, so
Expand All @@ -26,7 +29,10 @@ const FIXTURE_VERSIONS: Record<string, string> = vi.hoisted(() => ({
'@cipherstash/stack': '9.9.9-test.1',
'@cipherstash/stack-supabase': '9.9.9-test.1',
}))
vi.mock('../../../../runtime-versions.js', () => ({
vi.mock('../../../../runtime-versions.js', async (importOriginal) => ({
// Keep the real pure helpers (compareVersions, parseEmbeddedVersions);
// override only the release map and the map-reading functions.
...(await importOriginal<typeof import('../../../../runtime-versions.js')>()),
RUNTIME_PACKAGE_VERSIONS: FIXTURE_VERSIONS,
expectedVersion: (
pkg: string,
Expand Down Expand Up @@ -216,6 +222,58 @@ describe('installDepsStep', () => {
expect(result.cliInstalled).toBe(true)
})

it('a NEWER install gets an update-stash warning, never a downgrade command', async () => {
vi.mocked(isInteractive).mockReturnValue(false)
present('@cipherstash/stack', 'stash')
resolvedVersions({
'@cipherstash/stack': '9.9.10',
stash: FIXTURE_VERSIONS.stash,
})

const result = await installDepsStep.run(baseState, provider)

expect(p.log.warn).toHaveBeenCalledWith(
expect.stringContaining(
'@cipherstash/stack: installed 9.9.10 is newer than this release of stash expects (9.9.9-test.1)',
),
)
// Lockstep means stash@<installed> must exist — the warning prints the
// exact update command instead of an uncommanded "matching release".
expect(p.log.warn).toHaveBeenCalledWith(
expect.stringContaining('npm install -D stash@9.9.10'),
)
// No align/downgrade guidance, no mutation, clean success.
expect(p.note).not.toHaveBeenCalled()
expect(execSyncMock).not.toHaveBeenCalled()
expect(result.stackInstalled).toBe(true)
})

it('mixed ahead + missing: warns that fresh installs pin to THIS release and may mismatch', async () => {
vi.mocked(isInteractive).mockReturnValue(false)
// stack is installed and NEWER; the supabase adapter is missing — init
// will install the adapter at this CLI's older embed, so it must say the
// pairing may not match rather than silently manufacturing a cross-train
// mismatch under a "likely fine" banner.
present('@cipherstash/stack', 'stash')
resolvedVersions({
'@cipherstash/stack': '9.9.10',
stash: FIXTURE_VERSIONS.stash,
})

await installDepsStep.run(baseState, supabaseProvider)

expect(p.log.warn).toHaveBeenCalledWith(
expect.stringContaining(
"@cipherstash/stack-supabase will be installed at THIS release's versions",
),
)
expect(p.log.warn).toHaveBeenCalledWith(
expect.stringContaining('npm install -D stash@9.9.10'),
)
// The missing adapter still installs (non-interactive default).
expect(execSyncMock).toHaveBeenCalled()
})

it('reports an unreadable manifest as skew, not as a matching install', async () => {
vi.mocked(isInteractive).mockReturnValue(false)
present('@cipherstash/stack', 'stash')
Expand Down Expand Up @@ -257,6 +315,24 @@ describe('versionSkew', () => {
pkg: '@cipherstash/stack',
installed: '0.19.0',
expected: '9.9.9-test.1',
direction: 'behind',
},
])
})

it('classifies a newer-than-expected install as ahead', () => {
present('@cipherstash/stack')
resolvedVersions({ '@cipherstash/stack': '9.9.10' })
expect(
versionSkew(['@cipherstash/stack'], {
'@cipherstash/stack': '9.9.9-test.1',
}),
).toEqual([
{
pkg: '@cipherstash/stack',
installed: '9.9.10',
expected: '9.9.9-test.1',
direction: 'ahead',
},
])
})
Expand All @@ -274,6 +350,23 @@ describe('versionSkew', () => {
expect(versionSkew(['@no-map/package'], {})).toEqual([])
})

it('classifies a garbage installed version as behind (align guidance), not ahead', () => {
present('@cipherstash/stack')
resolvedVersions({ '@cipherstash/stack': 'not-a-version' })
expect(
versionSkew(['@cipherstash/stack'], {
'@cipherstash/stack': '9.9.9-test.1',
}),
).toEqual([
{
pkg: '@cipherstash/stack',
installed: 'not-a-version',
expected: '9.9.9-test.1',
direction: 'behind',
},
])
})

it('flags an installed package with an unreadable manifest', () => {
present('@cipherstash/stack')
resolvedVersions({ '@cipherstash/stack': undefined })
Expand All @@ -286,6 +379,8 @@ describe('versionSkew', () => {
pkg: '@cipherstash/stack',
installed: 'unknown (unreadable package.json)',
expected: '9.9.9-test.1',
// Unreadable = broken install → offer the (re)install fix.
direction: 'behind',
},
])
})
Expand Down
58 changes: 56 additions & 2 deletions packages/cli/src/commands/init/steps/install-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execSync } from 'node:child_process'
import * as p from '@clack/prompts'
import { isInteractive } from '../../../config/tty.js'
import {
compareVersions,
expectedVersion,
pinnedSpec,
RUNTIME_PACKAGE_VERSIONS,
Expand All @@ -11,6 +12,7 @@ import { CancelledError } from '../types.js'
import {
combinedInstallCommands,
detectPackageManager,
devInstallCommand,
installedVersion,
isPackageInstalled,
} from '../utils.js'
Expand Down Expand Up @@ -49,6 +51,10 @@ export type VersionSkewEntry = {
pkg: string
installed: string
expected: string
/** `behind`: older than this release (or unreadable) — offer alignment.
* `ahead`: NEWER than this release expects — the install is likely fine
* and the fix is updating stash, never downgrading the package. */
direction: 'behind' | 'ahead'
}

/**
Expand All @@ -71,7 +77,15 @@ export function versionSkew(
if (!expected) continue
if (!isPackageInstalled(pkg)) continue
const installed = installedVersion(pkg) ?? UNREADABLE_VERSION
if (installed !== expected) skewed.push({ pkg, installed, expected })
if (installed === expected) continue
// Unreadable manifests are treated as `behind`: a broken install should
// be offered the (re)install fix, not a stash upgrade.
const direction =
installed !== UNREADABLE_VERSION &&
compareVersions(installed, expected) > 0
? ('ahead' as const)
: ('behind' as const)
skewed.push({ pkg, installed, expected, direction })
}
return skewed
}
Expand All @@ -87,6 +101,17 @@ function skewLines(skewed: readonly VersionSkewEntry[]): string {
.join('\n ')
}

/** Render the newer-than-expected lines: the fix is updating stash, never
* downgrading a runtime package past releases the project already uses. */
function aheadLines(ahead: readonly VersionSkewEntry[]): string {
return ahead
.map(
({ pkg, installed, expected }) =>
`${pkg}: installed ${installed} is newer than this release of stash expects (${expected})`,
)
.join('\n ')
}

/** Split pinned install specs into (prod, dev) lists — `stash` is a dev
* dependency by init's own convention; everything else is prod. */
function splitProdDev(packages: readonly string[]): {
Expand Down Expand Up @@ -151,7 +176,13 @@ export const installDepsStep: InitStep = {
// failure, or early return can skip it (#661). Every path below inherits
// this warning.
const pm = detectPackageManager()
const skewed = versionSkew(allPackages)
const allSkew = versionSkew(allPackages)
// Direction matters (#666 review): only packages BEHIND this release get
// the align treatment. A package AHEAD of this release means the CLI is
// the stale side — advising a downgrade would walk the project back past
// releases it already depends on.
const skewed = allSkew.filter(({ direction }) => direction === 'behind')
const ahead = allSkew.filter(({ direction }) => direction === 'ahead')
const alignSplit = splitProdDev(skewed.map(({ pkg }) => pkg))
const alignCommands = combinedInstallCommands(
pm,
Expand All @@ -169,6 +200,29 @@ export const installDepsStep: InitStep = {
if (!cliPresent) missing.push(CLI_PACKAGE)
const missingSplit = splitProdDev(missing)

if (ahead.length > 0) {
// Every release-train package versions in lockstep (the changesets
// `fixed` group), so a train package strictly ahead of this CLI's embed
// implies a stash release exists at that exact version — print the
// command instead of leaving the user to research "the matching
// release". Highest ahead version wins when several differ.
const target = ahead
.map(({ installed }) => installed)
.reduce((max, v) => (compareVersions(v, max) > 0 ? v : max))
const updateCmd = devInstallCommand(pm, `stash@${target}`)
// Installing MISSING packages now would pin them to this CLI's older
// embed, pairing them with the newer installed packages — a combination
// no lockstep release ever shipped. Say so instead of silently
// manufacturing the mismatch.
const missingNote =
missing.length > 0
? `\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}`
: `\nUpdate with:\n ${updateCmd}\nthen re-run init.`
p.log.warn(
`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}`,
)
}

// Interactively, skewed packages can be aligned in the same install run.
// Non-interactive runs never mutate an existing install: agents/CI get
// the warning + exact commands above and keep going.
Expand Down
Loading
Loading