Skip to content

Commit 9e575f1

Browse files
committed
ci: forward roll durable state on the core lane, without packaging
Durable-state decoders read what an already-installed build wrote, and nothing in the core lane exercised them that way — retiring a persisted operation grant stayed green while workspaces holding an older credential could no longer start. The released State Root qualification is the check that would have caught it, but it only ran behind CLI packaging, for changes to that packaging. Select it from the test planner instead, on the decoders themselves, and run it in the core job against the workspace already built there. A measured run of the packaged lane put the qualification at ~1m16s behind ~9m22s of Rust addon and tarball builds; none of that packaging changes whether the compiled decoders can read released state, so the core lane skips it and keeps only the part that proves something. The source stays an exact published Nightly, resolved and integrity-checked the same way the release lane does: what must be real is the state being read, not the build reading it. Reports from a workspace target say so rather than carrying a tarball digest, so this run is never mistaken for evidence about a release. The release lanes are untouched and still qualify exact published tarballs end to end. The planner matches sqlite-*-schema.ts by shape: a new schema file is exactly the kind of change that must select this lane on the commit introducing it. Generated-by: Claude Code (Opus 5)
1 parent 406786e commit 9e575f1

5 files changed

Lines changed: 212 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,13 @@ jobs:
127127
restore-keys: electron-${{ runner.os }}-
128128

129129
- name: Install Linux runtime dependencies
130-
if: steps.plan.outputs.runtime_sandbox == 'true'
130+
if: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == 'true'
131131
run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap
132132

133133
# Ubuntu 24.04 hosted runners gate unprivileged user namespaces through
134134
# AppArmor, which otherwise makes bwrap fail while configuring loopback.
135135
- name: Enable bubblewrap user namespaces
136-
if: steps.plan.outputs.runtime_sandbox == 'true'
136+
if: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == 'true'
137137
run: |
138138
if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
139139
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
@@ -251,6 +251,51 @@ jobs:
251251
if: steps.plan.outputs.runtime_host == 'true'
252252
run: npm --workspace @maka/runtime-host run test:dist
253253

254+
# A durable-state decoder reads what an already-installed build wrote,
255+
# with no peer present to negotiate a version, so nothing but a real
256+
# forward roll exercises it the way a user's upgrade does. The published
257+
# Nightly supplies the state; the workspace built above opens it. Release
258+
# packaging is deliberately not in front of this: it takes minutes, and
259+
# none of it changes whether the compiled decoders can read that state.
260+
# The release lanes still qualify exact published tarballs (#4427).
261+
- id: forward-roll-baseline
262+
name: Resolve the published forward-roll baseline
263+
if: steps.plan.outputs.state_root_compat == 'true'
264+
run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT"
265+
266+
- name: Download the forward-roll baseline
267+
if: steps.plan.outputs.state_root_compat == 'true'
268+
env:
269+
SOURCE_URL: ${{ steps.forward-roll-baseline.outputs.tarball_url }}
270+
SOURCE_INTEGRITY: ${{ steps.forward-roll-baseline.outputs.integrity }}
271+
run: |
272+
set -euo pipefail
273+
source_path="$RUNNER_TEMP/forward-roll-source.tgz"
274+
curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$SOURCE_URL" --output "$source_path"
275+
node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE'
276+
const { createHash } = require('node:crypto');
277+
const { readFileSync } = require('node:fs');
278+
const bytes = readFileSync(process.argv[2]);
279+
const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
280+
if (actual !== process.argv[3]) throw new Error('Forward-roll baseline integrity mismatch');
281+
NODE
282+
{
283+
echo "FORWARD_ROLL_SOURCE=$source_path"
284+
echo "FORWARD_ROLL_SOURCE_SHA256=$(sha256sum "$source_path" | cut -d ' ' -f 1)"
285+
} >> "$GITHUB_ENV"
286+
287+
- name: Qualify durable state against the published baseline
288+
if: steps.plan.outputs.state_root_compat == 'true'
289+
env:
290+
MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1'
291+
run: |
292+
set -o pipefail
293+
npm run --silent release:cli:qualify-state-root -- \
294+
--source "$FORWARD_ROLL_SOURCE" \
295+
--source-sha256 "$FORWARD_ROLL_SOURCE_SHA256" \
296+
--target-workspace "$PWD" \
297+
| tee "$RUNNER_TEMP/durable-state-report.json"
298+
254299
- name: Ensure xvfb
255300
if: steps.plan.outputs.e2e == 'true'
256301
run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; }

scripts/ci-test-plan.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ const RELEASE_CONTRACT_FILES = new Set([
7575
'scripts/windows-package-source-closure.test.mjs',
7676
]);
7777

78+
// Decoders whose input is durable state an already-installed build wrote. No
79+
// peer is present to negotiate a version there, so the only check that
80+
// exercises them the way a user's upgrade does is a real forward roll from a
81+
// published release onto this tree.
82+
const DURABLE_STATE_DECODER_FILES = new Set([
83+
'packages/runtime-host/src/server/access-authority.ts',
84+
'packages/runtime-host/src/server/access-credential-store.ts',
85+
'packages/storage/src/operational-state-store.ts',
86+
'packages/storage/src/root-authority.ts',
87+
'packages/storage/src/state-root-composition.ts',
88+
'scripts/qualify-released-cli-state-root.mjs',
89+
'scripts/released-cli-state-root-fixture.mjs',
90+
]);
91+
7892
const TYPECHECK_ONLY_FILES = new Set([
7993
'biome.jsonc',
8094
'components.json',
@@ -350,6 +364,7 @@ export function planTests(changedFiles, options = {}) {
350364
full: true,
351365
releaseContract: true,
352366
runtimeSandbox: graph.dirs.includes('packages/cli'),
367+
stateRootCompat: true,
353368
// A complete functional suite is still the default release/main gate.
354369
// Stress multipliers and native child-process lock probes run only when
355370
// their owning storage seam changes; making --full imply stress turned
@@ -421,6 +436,14 @@ export function planTests(changedFiles, options = {}) {
421436
// the cli workspace runs in the dependency closure, not only for direct
422437
// cli/runtime edits (e.g. a storage-only change still selects cli via runtime).
423438
runtimeSandbox: workspaces.includes('packages/cli'),
439+
// The SQLite schemas are matched by shape rather than named one by one:
440+
// a new sqlite-*-schema.ts is exactly the kind of file that must select
441+
// this lane on the commit that introduces it.
442+
stateRootCompat: files.some(
443+
(path) =>
444+
DURABLE_STATE_DECODER_FILES.has(path) ||
445+
/^packages\/storage\/src\/sqlite-[^/]*schema[^/]*\.ts$/u.test(path),
446+
),
424447
storageStress,
425448
// Storybook build + smoke: catalog/harness only. Not every desktop/ui/core
426449
// PR — product ship gates are typecheck, unit, and Electron e2e. See
@@ -441,6 +464,7 @@ export function formatGitHubOutputs(plan) {
441464
`runtime_host=${plan.runtimeHost}`,
442465
`runtime_sandbox=${plan.runtimeSandbox}`,
443466
`release_contract=${plan.releaseContract}`,
467+
`state_root_compat=${plan.stateRootCompat}`,
444468
`storage_stress=${plan.storageStress}`,
445469
`storybook=${plan.storybook}`,
446470
`standard_workspaces=${plan.standardWorkspaces.join(',')}`,

scripts/ci-test-plan.test.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,3 +820,32 @@ function checkoutSteps(name) {
820820
[]
821821
);
822822
}
823+
824+
test('durable-state decoders select the published forward roll', () => {
825+
// Each of these reads state an already-installed build wrote. Losing this
826+
// selection is silent: the lane stops running and every check stays green,
827+
// which is how a retired operation grant reached users.
828+
for (const path of [
829+
'packages/runtime-host/src/server/access-credential-store.ts',
830+
'packages/runtime-host/src/server/access-authority.ts',
831+
'packages/storage/src/root-authority.ts',
832+
'packages/storage/src/operational-state-store.ts',
833+
'packages/storage/src/state-root-composition.ts',
834+
'packages/storage/src/sqlite-workflow-schema.ts',
835+
'packages/storage/src/sqlite-session-metadata-schema.ts',
836+
'scripts/qualify-released-cli-state-root.mjs',
837+
'scripts/released-cli-state-root-fixture.mjs',
838+
]) {
839+
assert.equal(planTests([path], { graph }).stateRootCompat, true, path);
840+
}
841+
});
842+
843+
test('ordinary changes do not pay for the published forward roll', () => {
844+
for (const path of [
845+
'docs/ci.md',
846+
'apps/desktop/src/renderer/app.tsx',
847+
'packages/storage/src/session-store.ts',
848+
]) {
849+
assert.equal(planTests([path], { graph }).stateRootCompat, false, path);
850+
}
851+
});

scripts/qualify-released-cli-state-root.mjs

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function parseQualificationArgs(argv) {
5151
'--target',
5252
'--source-sha256',
5353
'--target-sha256',
54+
'--target-workspace',
5455
'--expect-epoch-relation',
5556
]);
5657
const values = new Map();
@@ -65,13 +66,29 @@ export function parseQualificationArgs(argv) {
6566
values.set(name, value);
6667
}
6768
const source = requireAbsolutePath(values, '--source');
68-
const target = requireAbsolutePath(values, '--target');
6969
const sourceSha256 = requireSha256(values, '--source-sha256');
70-
const targetSha256 = requireSha256(values, '--target-sha256');
7170
const expectedEpochRelation = values.get('--expect-epoch-relation') ?? 'any';
7271
if (!['same', 'different', 'any'].includes(expectedEpochRelation)) {
7372
throw new Error('Expected epoch relation must be same, different, or any');
7473
}
74+
// A pull request qualifies the built workspace rather than a packaged
75+
// artifact: what decides whether durable state still opens is the compiled
76+
// storage and Runtime Host code, and packaging only moves it. The release
77+
// lanes keep naming an exact tarball, which is why identity stays required
78+
// there rather than optional everywhere.
79+
if (values.has('--target-workspace')) {
80+
if (values.has('--target') || values.has('--target-sha256')) {
81+
throw new Error('A workspace target cannot also name a tarball target');
82+
}
83+
return {
84+
source,
85+
sourceSha256,
86+
targetWorkspace: requireAbsolutePath(values, '--target-workspace'),
87+
expectedEpochRelation,
88+
};
89+
}
90+
const target = requireAbsolutePath(values, '--target');
91+
const targetSha256 = requireSha256(values, '--target-sha256');
7592
return { source, target, sourceSha256, targetSha256, expectedEpochRelation };
7693
}
7794

@@ -135,7 +152,9 @@ export async function qualifyReleasedCliStateRoot(input) {
135152
assertCommandAvailable('/usr/bin/setpriv');
136153
}
137154
assertTarballDigest(input.source, input.sourceSha256, 'source');
138-
assertTarballDigest(input.target, input.targetSha256, 'target');
155+
if (!input.targetWorkspace) {
156+
assertTarballDigest(input.target, input.targetSha256, 'target');
157+
}
139158
const scope = mkdtempSync(join(tmpdir(), 'maka-released-state-root-'));
140159
try {
141160
const sandbox = prepareSandbox(scope);
@@ -145,12 +164,14 @@ export async function qualifyReleasedCliStateRoot(input) {
145164
scope,
146165
sandbox,
147166
});
148-
const target = installArtifact({
149-
role: 'target',
150-
tarball: input.target,
151-
scope,
152-
sandbox,
153-
});
167+
const target = input.targetWorkspace
168+
? workspaceArtifact({ repoRoot: input.targetWorkspace, scope, sandbox })
169+
: installArtifact({
170+
role: 'target',
171+
tarball: input.target,
172+
scope,
173+
sandbox,
174+
});
154175
const epochRelation = assertExpectedEpochRelation(
155176
source.compatibilityEpoch,
156177
target.compatibilityEpoch,
@@ -353,6 +374,42 @@ function installArtifact({ role, tarball, scope, sandbox }) {
353374
return { role, prefix, packageRoot, cliPath, version, compatibilityEpoch: Number(epoch) };
354375
}
355376

377+
// The workspace already carries the compiled packages the fixture loads: npm
378+
// workspaces link node_modules/@maka/* at the repository root, which is the
379+
// same shape an installed tarball presents. Nothing is installed, so the
380+
// packaging chain in front of this check is skipped entirely.
381+
function workspaceArtifact({ repoRoot, scope, sandbox }) {
382+
const cliPath = join(repoRoot, 'packages/cli/dist/cli.js');
383+
const protocolPath = join(repoRoot, 'node_modules/@maka/runtime-host/dist/protocol/index.js');
384+
for (const required of [cliPath, protocolPath]) {
385+
if (!existsSync(required)) {
386+
throw new Error(`The workspace target is not built: ${required} is missing`);
387+
}
388+
}
389+
const epoch = readFileSync(protocolPath, 'utf8').match(
390+
/RUNTIME_HOST_COMPATIBILITY_EPOCH\s*=\s*(\d+)/u,
391+
)?.[1];
392+
if (!epoch) throw new Error('The workspace target has no compatibility epoch');
393+
const versionResult = spawnSync(process.execPath, [cliPath, '--version'], {
394+
cwd: scope,
395+
env: sandbox.environment,
396+
encoding: 'utf8',
397+
maxBuffer: MAX_OUTPUT_BYTES,
398+
timeout: PROCESS_TIMEOUT_MS,
399+
});
400+
if (versionResult.status !== 0) {
401+
throw new Error(`The workspace CLI version check failed: ${versionResult.stderr}`);
402+
}
403+
return {
404+
role: 'target',
405+
kind: 'workspace',
406+
packageRoot: repoRoot,
407+
cliPath,
408+
version: versionResult.stdout.trim(),
409+
compatibilityEpoch: Number(epoch),
410+
};
411+
}
412+
356413
export function qualificationSandboxArgs({ innerInputPath, sandbox, scope }) {
357414
return [
358415
'--die-with-parent',
@@ -589,6 +646,16 @@ function restoreGolden(locations) {
589646
}
590647

591648
function artifactEvidence(artifact, sha256) {
649+
// A workspace target has no published identity to pin, and saying so keeps
650+
// this report from reading as evidence about a release it never touched.
651+
if (artifact.kind === 'workspace') {
652+
return {
653+
version: artifact.version,
654+
compatibilityEpoch: artifact.compatibilityEpoch,
655+
kind: 'workspace',
656+
sha256: 'not_applicable',
657+
};
658+
}
592659
return {
593660
version: artifact.version,
594661
compatibilityEpoch: artifact.compatibilityEpoch,

scripts/qualify-released-cli-state-root.test.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,39 @@ test('durable state covers the control namespace, not only the State Root', () =
209209
assert.ok(!golden.startsWith(`${live}/`), 'a golden copy must not nest inside its live path');
210210
}
211211
});
212+
213+
test('a workspace target replaces tarball identity instead of weakening it', () => {
214+
const source = resolve(tmpdir(), 'source.tgz');
215+
const repo = resolve(tmpdir(), 'checkout');
216+
assert.deepEqual(
217+
parseQualificationArgs([
218+
'--source',
219+
source,
220+
'--source-sha256',
221+
SHA_A,
222+
'--target-workspace',
223+
repo,
224+
]),
225+
{ source, sourceSha256: SHA_A, targetWorkspace: repo, expectedEpochRelation: 'any' },
226+
);
227+
// The source stays an exact published artifact either way: the point of the
228+
// run is that state written by a real release still opens.
229+
assert.throws(
230+
() => parseQualificationArgs(['--target-workspace', repo]),
231+
/--source must be an absolute path/u,
232+
);
233+
assert.throws(
234+
() =>
235+
parseQualificationArgs([
236+
'--source',
237+
source,
238+
'--source-sha256',
239+
SHA_A,
240+
'--target-workspace',
241+
repo,
242+
'--target-sha256',
243+
SHA_B,
244+
]),
245+
/cannot also name a tarball target/u,
246+
);
247+
});

0 commit comments

Comments
 (0)