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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,13 @@ Every command and flag named in `skills/stash-cli/SKILL.md` must resolve against
manifest (the deprecated `db install` / `db upgrade` / `db status` aliases excepted —
they're intentionally absent from the registry).

**The version step moves the release-train pins.** `scripts/sync-skill-pins.mjs`
runs from the root `version` script after `changeset version`. It rewrites every
exact pin of a release-train package in `skills/`, such as
`npx --package=stash@X.Y.Z` and `npm:@cipherstash/stack@X.Y.Z/wasm-inline`, to the
stable version in the tree, so the Version Packages PR carries them. Name an older
release in prose without the `name@X.Y.Z` form, or the script moves it too.

Skills must not contain Linear issue IDs; they're public. GitHub issue numbers are fine.

## Supply Chain Security
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs",
"lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
"release:gate": "node scripts/release-gate.mjs",
"version": "changeset version && node scripts/sync-lockstep-versions.mjs",
"version": "changeset version && node scripts/sync-skill-pins.mjs && node scripts/sync-lockstep-versions.mjs",
"release": "pnpm run build && changeset publish",
"test": "turbo test --filter './languages/typescript/packages/**' --filter './packages/**'",
"test:e2e": "turbo run test:e2e",
Expand Down
5 changes: 4 additions & 1 deletion packages/eql/docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ at one commit.
The root `version` script is the mechanism:

```
pnpm run version == changeset version && node scripts/sync-lockstep-versions.mjs
pnpm run version == changeset version && node scripts/sync-skill-pins.mjs && node scripts/sync-lockstep-versions.mjs
```

`scripts/sync-skill-pins.mjs` moves the `stash` release-train pins in `skills/`
and does not touch EQL.

1. `changeset version` consumes the pending `.changeset/*.md` files and bumps
`packages/eql/package.json` to the next `V`.
2. `scripts/sync-lockstep-versions.mjs` reads that `V` and propagates it:
Expand Down
261 changes: 250 additions & 11 deletions scripts/__tests__/sync-lockstep-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
cpSync,
mkdirSync,
Expand All @@ -16,10 +17,11 @@ import { afterAll, describe, expect, test } from 'vitest'
import {
bumpCargoPackageVersion,
cargoLockWorkspaces,
LOCKED_CRATE,
pathLockedVersion,
prepareBindingAssets,
readEqlVersion,
refreshCargoLock,
refreshCargoLocks,
} from '../sync-lockstep-versions.mjs'
import { REPO_ROOT } from './lib/repo-root.mjs'
import { readWorkflow } from './lib/workflows.mjs'
Expand Down Expand Up @@ -137,22 +139,35 @@ describe('lockstep Cargo.lock refresh', () => {
expect(args.slice(0, 3)).toEqual(['exec', '--', 'cargo'])
expect(options.cwd).toBe('/repo/packages/eql')

expect(args).toContain('--package')
expect(args).toContain(LOCKED_CRATE)
expect(args).toContain('--workspace')
expect(args).toContain('--manifest-path')
expect(args).toContain(
'/repo/languages/typescript/packages/protect-ffi/Cargo.toml',
)
})

/**
* `--package eql-bindings` re-picks every edge whose requirement spans
* several semver-incompatible `windows-sys` versions, and each run moves
* them: on `main`'s lock it rewrites three, and on its own output it rewrites
* them again. Version Packages #1020 shipped one such line with no EQL bump.
* Measured with cargo 1.90.0, 1.94.1 and 1.99.0, which agree; `--workspace`
* changes only the `eql-bindings` version line, with or without a bump.
*/
test('does not pass --package, which rewrites unrelated windows-sys edges', () => {
const [, args] = captureRefresh()
expect(args).not.toContain('--package')
expect(args).not.toContain('-p')
})

/**
* NOT `--offline`, and the reason is a property of the job this runs in.
*
* `--offline` reads as free here — `eql-bindings` resolves from a path, so
* why would refreshing it need a registry? Because `cargo update -p X` does
* not update X in isolation: it re-resolves the WHOLE graph and rewrites a
* complete lock, and in offline mode every other package has to come from
* the local registry cache. `languages/typescript/packages/protect-ffi` has 167 of them.
* why would refreshing it need a registry? Because `cargo update` re-resolves
* the WHOLE graph, even with `--workspace`, and in offline mode every other
* package has to come from the local registry cache.
* `languages/typescript/packages/protect-ffi` has 167 of them.
*
* The release job has no such cache. `jdx/mise-action` runs there with
* `install: true, cache: false` — it installs toolchains and populates
Expand All @@ -169,10 +184,9 @@ describe('lockstep Cargo.lock refresh', () => {
* half-applied-release failure the mise-install step above exists to prevent.
*
* The measurement that made `--offline` look safe was taken on a developer
* machine with a warm `~/.cargo`. Both resolutions agree — verified on the
* 3.0.4 -> 3.0.5 bump, byte-identical including the `windows-sys` edges cargo
* repaired along the way — so dropping the flag changes nothing except
* whether the step can run at all where it actually runs.
* machine with a warm `~/.cargo`. Both resolutions agree where both can run,
* so dropping the flag changes nothing except whether the step can run at
* all where it actually runs.
*/
test('does not pass --offline, which cannot resolve on a cold registry', () => {
const [, args] = captureRefresh()
Expand Down Expand Up @@ -221,6 +235,141 @@ describe('lockstep Cargo.lock refresh', () => {
})
})

/** A `Cargo.lock` with `eql-bindings` from a path at `version`, and a registry crate. */
const lockAt = (version) => `# This file is automatically @generated by Cargo.
version = 4

[[package]]
name = "eql-bindings"
version = "${version}"
dependencies = [
"serde",
]

[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
`

describe('step 3 runs cargo only for a lock that needs it', () => {
test('reads the version a lock records for the crate from a path', () => {
expect(pathLockedVersion(lockAt('3.0.6'))).toBe('3.0.6')
// From a registry it is a different crate as far as this script cares.
expect(
pathLockedVersion(
'[[package]]\nname = "eql-bindings"\nversion = "3.0.6"\nsource = "registry+x"\n',
),
).toBeNull()
expect(pathLockedVersion('version = 4\n')).toBeNull()
})

/** A tree of workspaces, each with a `Cargo.lock` at the given version. */
function tree(locks) {
const root = mkdtempSync(join(tmpdir(), 'lockstep-locks-'))
for (const [workspace, version] of Object.entries(locks)) {
mkdirSync(join(root, workspace), { recursive: true })
writeFileSync(join(root, workspace, 'Cargo.lock'), lockAt(version))
}
return root
}

/** A `run` that records each workspace cargo was pointed at, and bumps its lock. */
function fakeCargo(writes = true) {
const calls = []
const run = (_command, args) => {
const manifest = args[args.indexOf('--manifest-path') + 1]
calls.push(manifest)
if (writes) {
writeFileSync(join(dirname(manifest), 'Cargo.lock'), lockAt('3.0.7'))
}
}
return { calls, run }
}

test('runs no cargo when every lock already records the version', () => {
// A release that does not bump EQL: nothing may be rewritten.
const root = tree({ 'a/ffi': '3.0.6', eql: '3.0.6' })
try {
const cargo = fakeCargo()
const result = refreshCargoLocks({
root,
eqlRoot: join(root, 'eql'),
version: '3.0.6',
run: cargo.run,
log: () => {},
})
expect(cargo.calls).toEqual([])
expect(result).toEqual({ workspaces: ['a/ffi', 'eql'], refreshed: [] })
expect(readFileSync(join(root, 'a/ffi/Cargo.lock'), 'utf8')).toBe(
lockAt('3.0.6'),
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

test('runs cargo for each lock at another version, and only those', () => {
const root = tree({ 'a/ffi': '3.0.6', eql: '3.0.7' })
try {
const cargo = fakeCargo()
const result = refreshCargoLocks({
root,
eqlRoot: join(root, 'eql'),
version: '3.0.7',
run: cargo.run,
log: () => {},
})
expect(cargo.calls).toEqual([join(root, 'a/ffi/Cargo.toml')])
expect(result.refreshed).toEqual(['a/ffi'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})

test('fails when cargo ran and the lock still records another version', () => {
const root = tree({ ffi: '3.0.6' })
try {
expect(() =>
refreshCargoLocks({
root,
eqlRoot: root,
version: '3.0.7',
run: fakeCargo(false).run,
log: () => {},
}),
).toThrow(/records eql-bindings 3\.0\.6, not 3\.0\.7/)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

test('runs no cargo over this tree at its own EQL version', () => {
// The committed locks against the committed version: what the release job
// sees on every release that leaves EQL alone.
const version = JSON.parse(
readFileSync(
join(REPO_ROOT, 'packages/eql/packages/eql/package.json'),
'utf8',
),
).version
// A recorder that writes nothing: this tree's locks are real files.
const cargo = fakeCargo(false)
try {
refreshCargoLocks({
root: REPO_ROOT,
eqlRoot: join(REPO_ROOT, 'packages/eql'),
version,
run: cargo.run,
log: () => {},
})
} catch {
// The after-check throws once cargo was called; the call is the finding.
}
expect(cargo.calls).toEqual([])
})
})

/**
* Step 4, and the release-stopper it used to arm.
*
Expand Down Expand Up @@ -490,6 +639,96 @@ describe('lockstep main-guard', () => {
})
})

/**
* Step 3 through the real script, with `mise` replaced on PATH by a recorder
* that bumps the lock it is pointed at. Step 4 then fails on the fixture's
* missing SQL assets; only what step 3 did is asserted.
*/
describe('the script runs cargo only for a stale lock', () => {
function runWithLock(lockedVersion) {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'lockstep-step3-')))
const bin = join(root, 'bin')
const lockPath = join(root, 'languages/ffi/Cargo.lock')
for (const dir of [
bin,
join(root, 'scripts'),
join(root, 'packages/eql/crates/eql-bindings'),
join(root, 'packages/eql/packages/eql'),
dirname(lockPath),
]) {
mkdirSync(dir, { recursive: true })
}
copyFileSync(
join(REPO_ROOT, 'scripts/sync-lockstep-versions.mjs'),
join(root, 'scripts/sync-lockstep-versions.mjs'),
)
writeFileSync(
join(root, 'packages/eql/packages/eql/package.json'),
JSON.stringify({ name: '@cipherstash/eql', version: '9.9.9' }),
)
writeFileSync(
join(root, 'packages/eql/crates/eql-bindings/Cargo.toml'),
'[package]\nname = "eql-bindings"\nversion = "9.9.8"\n',
)
writeFileSync(lockPath, lockAt(lockedVersion))
writeFileSync(
join(bin, 'mise'),
'#!/usr/bin/env node\n' +
"const fs = require('node:fs'), path = require('node:path')\n" +
'const args = process.argv.slice(2)\n' +
"fs.appendFileSync(process.env.MISE_LOG, args.join(' ') + '\\n')\n" +
"const at = args.indexOf('--manifest-path')\n" +
'if (at !== -1) {\n' +
" const lock = path.join(path.dirname(args[at + 1]), 'Cargo.lock')\n" +
' fs.writeFileSync(lock, fs.readFileSync(lock, \'utf8\').replace(/^version = "9\\.9\\.8"$/m, \'version = "9.9.9"\'))\n' +
'}\n',
)
chmodSync(join(bin, 'mise'), 0o755)
const miseLog = join(root, 'mise.log')
writeFileSync(miseLog, '')
const { stdout } = spawnSync(
process.execPath,
[join(root, 'scripts/sync-lockstep-versions.mjs')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
MISE_LOG: miseLog,
},
},
)
const result = {
stdout,
calls: readFileSync(miseLog, 'utf8').split('\n').filter(Boolean),
lock: readFileSync(lockPath, 'utf8'),
}
rmSync(root, { recursive: true, force: true })
return result
}

test('leaves a lock that already records the version untouched', () => {
const { stdout, calls, lock } = runWithLock('9.9.9')
// Proof that step 3 ran and chose to skip, rather than never being reached.
expect(stdout).toContain(
'languages/ffi/Cargo.lock already records eql-bindings 9.9.9',
)
expect(calls.filter((call) => call.includes('cargo'))).toEqual([])
expect(lock).toBe(lockAt('9.9.9'))
})

test('refreshes a lock at the previous version', () => {
const { calls, lock } = runWithLock('9.9.8')
expect(calls.filter((call) => call.includes('cargo'))).toEqual([
expect.stringMatching(
/^exec -- cargo update --workspace --manifest-path .*\/languages\/ffi\/Cargo\.toml$/,
),
])
expect(pathLockedVersion(lock)).toBe('9.9.9')
})
})

/**
* The invariant the whole script exists to maintain, asserted against the
* committed tree.
Expand Down
Loading
Loading