Skip to content

Commit eb7a3a1

Browse files
committed
ci: run the released forward roll when durable state decoders change
The forward-roll job existed and already ran, but nothing woke it when the code that decodes durable state changed. The trigger now selects it from the decoders and, decisively, from the operation vocabulary they decode against: the SessionTodo cutover that caused #4420 changed `protocol/operations.ts` and no decoder, so a decoder-only trigger stays green on the exact change shape this guard exists to catch. The planner test pins that path. It runs on the heavy lane rather than the CLI packaging lane. Packaging is nine minutes that prove nothing this check needs; the baseline is instead the published predecessor, downloaded and integrity-checked against the registry's own digest, and read by the workspace already built on that lane. That costs about a minute of wall clock on roughly one commit in ten, against a full cross-platform matrix of runner time — and runner time is the scarcer resource here, which is why this lane is one job of serial steps to begin with. Generated-by: Claude Code (claude-opus-5)
1 parent f05b1e5 commit eb7a3a1

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ jobs:
4848
release_contract: ${{ steps.plan.outputs.release_contract }}
4949
runtime_host: ${{ steps.plan.outputs.runtime_host }}
5050
runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }}
51+
state_root_compat: ${{ steps.plan.outputs.state_root_compat }}
5152
storage_stress: ${{ steps.plan.outputs.storage_stress }}
5253
storybook: ${{ steps.plan.outputs.storybook }}
5354
standard_workspaces: ${{ steps.plan.outputs.standard_workspaces }}
@@ -156,13 +157,13 @@ jobs:
156157
restore-keys: electron-${{ runner.os }}-
157158

158159
- name: Install Linux runtime dependencies
159-
if: needs.plan.outputs.runtime_sandbox == 'true'
160+
if: needs.plan.outputs.runtime_sandbox == 'true' || needs.plan.outputs.state_root_compat == 'true'
160161
run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap
161162

162163
# Ubuntu 24.04 hosted runners gate unprivileged user namespaces through
163164
# AppArmor, which otherwise makes bwrap fail while configuring loopback.
164165
- name: Enable bubblewrap user namespaces
165-
if: needs.plan.outputs.runtime_sandbox == 'true'
166+
if: needs.plan.outputs.runtime_sandbox == 'true' || needs.plan.outputs.state_root_compat == 'true'
166167
run: |
167168
if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
168169
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
@@ -280,6 +281,49 @@ jobs:
280281
if: needs.plan.outputs.runtime_host == 'true'
281282
run: npm --workspace @maka/runtime-host run test:dist
282283

284+
# A published predecessor writes the durable state; the workspace built
285+
# above reads it. Release packaging is deliberately not in front of this:
286+
# it takes minutes and changes nothing about whether these decoders can
287+
# read that state. The release lanes still qualify exact tarballs.
288+
- id: forward-roll-baseline
289+
name: Resolve the published forward-roll baseline
290+
if: needs.plan.outputs.state_root_compat == 'true'
291+
run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT"
292+
293+
- name: Download the forward-roll baseline
294+
if: needs.plan.outputs.state_root_compat == 'true'
295+
env:
296+
SOURCE_URL: ${{ steps.forward-roll-baseline.outputs.tarball_url }}
297+
SOURCE_INTEGRITY: ${{ steps.forward-roll-baseline.outputs.integrity }}
298+
run: |
299+
set -euo pipefail
300+
source_path="$RUNNER_TEMP/forward-roll-source.tgz"
301+
curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 \
302+
--retry 3 --retry-connrefused --retry-delay 2 "$SOURCE_URL" --output "$source_path"
303+
node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE'
304+
const { createHash } = require('node:crypto');
305+
const { readFileSync } = require('node:fs');
306+
const bytes = readFileSync(process.argv[2]);
307+
const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
308+
if (actual !== process.argv[3]) throw new Error('Forward-roll baseline integrity mismatch');
309+
NODE
310+
{
311+
echo "FORWARD_ROLL_SOURCE=$source_path"
312+
echo "FORWARD_ROLL_SOURCE_SHA256=$(sha256sum "$source_path" | cut -d ' ' -f 1)"
313+
} >> "$GITHUB_ENV"
314+
315+
- name: Qualify durable state against the published baseline
316+
if: needs.plan.outputs.state_root_compat == 'true'
317+
env:
318+
MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1'
319+
run: |
320+
set -o pipefail
321+
npm run --silent release:cli:qualify-state-root -- \
322+
--source "$FORWARD_ROLL_SOURCE" \
323+
--source-sha256 "$FORWARD_ROLL_SOURCE_SHA256" \
324+
--target-workspace "$PWD" \
325+
| tee "$RUNNER_TEMP/durable-state-report.json"
326+
283327
- name: Ensure xvfb
284328
if: needs.plan.outputs.e2e == 'true'
285329
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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ const RELEASE_CONTRACT_FILES = new Set([
7777
'scripts/windows-package-source-closure.test.mjs',
7878
]);
7979

80+
// What decides whether a build can read durable state an earlier release wrote.
81+
// `operations.ts` is here because it owns the operation vocabulary: the rename
82+
// that stranded workspaces holding an older credential (#4420) changed that file
83+
// and none of the decoders, so a trigger listing only decoders would not have
84+
// run on the very change it exists to catch.
85+
const DURABLE_STATE_DECODER_FILES = new Set([
86+
'packages/runtime-host/src/protocol/operations.ts',
87+
'packages/runtime-host/src/server/access-authority.ts',
88+
'packages/runtime-host/src/server/access-credential-store.ts',
89+
'packages/storage/src/operational-state-store.ts',
90+
'packages/storage/src/root-authority.ts',
91+
'packages/storage/src/state-root-composition.ts',
92+
'scripts/qualify-released-cli-state-root.mjs',
93+
'scripts/released-cli-state-root-fixture.mjs',
94+
]);
95+
8096
const TYPECHECK_ONLY_FILES = new Set([
8197
'biome.jsonc',
8298
'components.json',
@@ -359,6 +375,7 @@ export function planTests(changedFiles, options = {}) {
359375
// Stress multipliers and native child-process lock probes run only when
360376
// their owning storage seam changes; making --full imply stress turned
361377
// every unrelated merge into a 10K-chunk pressure run.
378+
stateRootCompat: true,
362379
storageStress: false,
363380
storybook: true,
364381
workspaces,
@@ -430,6 +447,14 @@ export function planTests(changedFiles, options = {}) {
430447
// the cli workspace runs in the dependency closure, not only for direct
431448
// cli/runtime edits (e.g. a storage-only change still selects cli via runtime).
432449
runtimeSandbox: workspaces.includes('packages/cli'),
450+
// The released forward roll: a build under test reads durable state a
451+
// published predecessor wrote. Selected by the decoders and the operation
452+
// vocabulary they decode against, plus the SQLite schemas.
453+
stateRootCompat: files.some(
454+
(path) =>
455+
DURABLE_STATE_DECODER_FILES.has(path) ||
456+
/^packages\/storage\/src\/sqlite-[^/]*schema[^/]*\.ts$/u.test(path),
457+
),
433458
storageStress,
434459
// Storybook build + smoke: catalog/harness only. Not every desktop/ui/core
435460
// PR — product ship gates are typecheck, unit, and Electron e2e. See
@@ -450,6 +475,7 @@ export function requiresHeavyValidation(plan) {
450475
plan.releaseContract ||
451476
plan.runtimeHost ||
452477
plan.runtimeSandbox ||
478+
plan.stateRootCompat ||
453479
plan.storybook ||
454480
plan.standardWorkspaces.length > 0,
455481
);
@@ -466,6 +492,7 @@ export function formatGitHubOutputs(plan) {
466492
`runtime_host=${plan.runtimeHost}`,
467493
`runtime_sandbox=${plan.runtimeSandbox}`,
468494
`release_contract=${plan.releaseContract}`,
495+
`state_root_compat=${plan.stateRootCompat}`,
469496
`storage_stress=${plan.storageStress}`,
470497
`storybook=${plan.storybook}`,
471498
`standard_workspaces=${plan.standardWorkspaces.join(',')}`,

scripts/ci-test-plan.test.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,37 @@ test('full-suite authority files select every surface', () => {
368368
}
369369
});
370370

371+
// The SessionTodo cutover (#4351) retired an operation and stranded every
372+
// workspace holding a credential issued before it (#4420). It changed the
373+
// vocabulary, not the decoders — so a trigger listing only decoders stays green
374+
// on the exact change shape this guard exists to catch.
375+
test('retiring an operation selects the released forward roll', () => {
376+
const plan = planTests(
377+
[
378+
'packages/runtime-host/src/protocol/operations.ts',
379+
'apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts',
380+
],
381+
{ graph },
382+
);
383+
384+
assert.equal(plan.stateRootCompat, true);
385+
assert.equal(plan.full, false);
386+
});
387+
388+
test('a durable-state decoder selects the released forward roll', () => {
389+
const plan = planTests(['packages/runtime-host/src/server/access-credential-store.ts'], {
390+
graph,
391+
});
392+
393+
assert.equal(plan.stateRootCompat, true);
394+
});
395+
396+
test('ordinary changes do not pay for the released forward roll', () => {
397+
const plan = planTests(['apps/desktop/src/renderer/features/workbar/ports.ts'], { graph });
398+
399+
assert.equal(plan.stateRootCompat, false);
400+
});
401+
371402
test('GitHub output matches the selections consumed by CI', () => {
372403
const output = formatGitHubOutputs(planTests([], { graph, forceFull: true }));
373404
const outputKeys = new Set(output.split('\n').map((line) => line.split('=', 1)[0]));

0 commit comments

Comments
 (0)