Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- **Maintenance:** Require the Vercel retention operator to run Node 22.19.0 or later in the 22.x line before source checks or provider/child-process access; the application's Node 22/24 CI matrix is unchanged. Issue #627. PR #1078.

- **Dependencies:** Update Tokio from 1.53.1 to 1.53.2 in the Tauri lockfile (PR #1076; Dependabot source PR #1044). No manifest or source changes.

- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no handle that opens the file can write to it while the removal holds it, with the exception of a writable mapped view that existed before, see below; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`, and a directory is looked at again right before the disposition so that one converted into a junction meanwhile is left alone (`Redirected`; narrows the window, does not close it). A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075.
Expand Down
9 changes: 9 additions & 0 deletions docs/VERCEL-RETENTION-RUNNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ record reconciler ([`PAGES-DEPLOYMENT-RECORD-PRUNING.md`](PAGES-DEPLOYMENT-RECOR

## Commands

Run the operator with Node **22.19.0 or a later 22.x patch/minor**, matching `.nvmrc`.
Both entry points check `process.versions.node` before source checks, external tools, runner
imports or lock acquisition. Node 24 and malformed/unsupported runtimes stop with
`RETENTION_STOP: runtime-mismatch`; there is no override. The application's Node 22/24 CI matrix
and general package engine range are unchanged. In a VS Code terminal, activate Node 22 first
and check `node -p 'process.versions.node'`; a shell's separate `node --version` is not proof of
an already running operator's runtime.

Two fixed commands, no arguments. Any other argument is refused, so a command-line allow rule for
them cannot be widened by appending text.

Expand Down Expand Up @@ -141,6 +149,7 @@ authentication stop, not something an allow rule fixes.

| Reason | Meaning |
| --- | --- |
| `runtime-mismatch` | The actual operator runtime is not Node 22 at or above 22.19.0. No external call or lock acquisition occurs. |
| `gate-not-green:<workflow>` | A required push run on `main` for the exact SHA is missing, in flight or not a success. |
| `runs-unreadable`, `runs-incomplete` | The run list could not be read, or was as long as the request limit and may be truncated. |
| `invalid-holds` | The hold file is missing, malformed or lists something other than deployment IDs. |
Expand Down
14 changes: 14 additions & 0 deletions scripts/vercel-retention/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ const stop = (reason) => {
throw Object.assign(new Error(reason), { reason });
};

// QNBS-v3: retention is a Node 22 operator contract, independent of the application's Node 22/24 CI matrix; reject the actual runtime before tools, locks or runner imports.
export function assertRuntime(version = process.versions.node) {
const match = typeof version === 'string' && /^22\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
if (
!match ||
match[0] !== version ||
!Number.isSafeInteger(Number(match[1])) ||
!Number.isSafeInteger(Number(match[2])) ||
Number(match[1]) < 19
)
stop('runtime-mismatch');
}

function parse(text, what) {
try {
return JSON.parse(text);
Expand Down Expand Up @@ -71,6 +84,7 @@ export function report(io, summary) {

// QNBS-v3: the manifest is printed from the plan, before the first write, so an operator can read exactly what a reconcile pass is about to do; the summary line follows the pass.
export async function main(argv, deps, io = console) {
assertRuntime();
const mode = parseMode(argv);
if (mode === 'reconcile') assertTrustedSource(deps);
const { runRetention } = await import('./apply.mjs');
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/tooling/support/vercelRetentionFixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ export function standardWorld() {
};
}

// QNBS-v3: behavior fixtures exercise the supported operator runtime even in the application Node 24 matrix; separate subprocess tests cover the real runtime refusal.
export async function withRetentionRuntime(action) {
const descriptor = Object.getOwnPropertyDescriptor(process.versions, 'node');
Object.defineProperty(process.versions, 'node', { ...descriptor, value: '22.23.3' });
try {
return await action();
} finally {
Object.defineProperty(process.versions, 'node', descriptor);
}
}

const stamp = (item) => item.createdAt ?? item.created;

function page(items, params, key, size) {
Expand Down
12 changes: 9 additions & 3 deletions tests/unit/tooling/vercelRetention.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
production,
SECRET,
standardWorld,
withRetentionRuntime,
} from './support/vercelRetentionFixture.mjs';

const OTHER = 'c'.repeat(40);
Expand Down Expand Up @@ -385,7 +386,9 @@ test('a modified, unmerged or divergent runner never mutates', async () => {
test('reconcile from an untrusted checkout performs no provider call at all', async () => {
const world = standardWorld();
world.differs = true;
const error = await stop(() => main(['reconcile'], makeDeps(world), { log() {} }));
const error = await stop(() =>
withRetentionRuntime(() => main(['reconcile'], makeDeps(world), { log() {} })),
);
assert.equal(error.reason, 'runner-source-differs-from-main');
assert.ok(world.calls.every((call) => !call.startsWith('vercel')));
});
Expand Down Expand Up @@ -426,7 +429,8 @@ test('the command line refuses an unknown mode before touching any tool', () =>
});
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(result.stderr, /RETENTION_STOP: invalid-arguments/);
const reason = process.versions.node.startsWith('22.') ? 'invalid-arguments' : 'runtime-mismatch';
assert.ok(result.stderr.includes(`RETENTION_STOP: ${reason}`));
});

test('mutations accept only an exact deployment ID or alias host and always pass --safe', () => {
Expand Down Expand Up @@ -522,7 +526,9 @@ test('a provider page without pagination metadata is malformed, not the final pa

test('the dry-run reports one manifest line per preview with its verdict and reasons', async () => {
const lines = [];
await main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) });
await withRetentionRuntime(() =>
main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) }),
);
const manifest = lines.filter((line) => line.startsWith('RETENTION_MANIFEST'));
assert.equal(manifest.length, 8);
assert.ok(manifest.some((line) => line.includes('dpl_prev1 | SAFE')));
Expand Down
10 changes: 6 additions & 4 deletions tests/unit/tooling/vercelRetention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { fileURLToPath } from 'node:url';
import { expect, it } from 'vitest';

// QNBS-v3: the lightweight Node suite runs standalone locally and through required Vitest CI without importing its node:test runner into Vitest.
it('passes the standalone Vercel retention authority, classification, mutation, lock and entry-point regressions', () => {
const paths = ['./vercelRetention.test.mjs', './vercelRetentionSafety.test.mjs'].map((name) =>
fileURLToPath(new URL(name, import.meta.url)),
);
it('passes the standalone Vercel retention authority, classification, mutation, lock, entry-point and runtime regressions', () => {
const paths = [
'./vercelRetention.test.mjs',
'./vercelRetentionSafety.test.mjs',
'./vercelRetentionRuntime.test.mjs',
].map((name) => fileURLToPath(new URL(name, import.meta.url)));
const output = execFileSync(process.execPath, ['--test', '--test-reporter=tap', ...paths], {
encoding: 'utf8',
timeout: 60000,
Expand Down
138 changes: 138 additions & 0 deletions tests/unit/tooling/vercelRetentionRuntime.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { assertRuntime, main } from '../../../scripts/vercel-retention/cli.mjs';
import {
makeDeps,
standardWorld,
withRetentionRuntime,
} from './support/vercelRetentionFixture.mjs';

const cli = fileURLToPath(new URL('../../../scripts/vercel-retention/cli.mjs', import.meta.url));

test('the operator contract matches .nvmrc without narrowing application engines or CI', () => {
assert.equal(readFileSync(new URL('../../../.nvmrc', import.meta.url), 'utf8').trim(), '22');
const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
assert.equal(pkg.engines.node, '>=22.19.0');
for (const version of ['22.19.0', '22.19.1', '22.23.3', '22.100.0'])
assert.doesNotThrow(() => assertRuntime(version));
for (const version of [
null,
'',
'unknown',
'v22.23.3',
'22.18.99',
'22.19',
'22.19.0-rc.1',
'22.19.0\n',
'22.019.0',
'22.19.00',
'22.19.9007199254740992',
'22.9007199254740992.0',
'20.19.0',
'21.0.0',
'23.0.0',
'24.18.0',
]) {
assert.throws(() => assertRuntime(version), { reason: 'runtime-mismatch' });
}
});

function guardedChild(argv, version) {
// QNBS-v3: test-only preload instruments every CLI/lock boundary and runner resolution; production has no runtime override or environment escape hatch.
const preload = `
import cp from 'node:child_process';
import fs from 'node:fs';
import { registerHooks, syncBuiltinESMExports } from 'node:module';
const calls = { child: 0, lock: 0, runner: 0, fetch: 0 };
const deny = (kind) => () => { calls[kind]++; throw new Error('unexpected-side-effect'); };
cp.execFileSync = deny('child');
for (const key of ['writeFileSync', 'linkSync', 'renameSync', 'rmSync']) fs[key] = deny('lock');
globalThis.fetch = deny('fetch');
syncBuiltinESMExports();
registerHooks({ resolve(specifier, context, next) {
if (specifier === './apply.mjs') { calls.runner++; throw new Error('runner-loaded'); }
return next(specifier, context);
}});
${version === undefined ? '' : `Object.defineProperty(process.versions, 'node', {value: ${JSON.stringify(version)}});`}
process.on('exit', () => process.stderr.write('GUARD_CALLS ' + JSON.stringify(calls) + '\\n'));
`;
const result = spawnSync(
process.execPath,
[`--import=data:text/javascript,${encodeURIComponent(preload)}`, cli, ...argv],
{ encoding: 'utf8', timeout: 15000 },
);
assert.ifError(result.error);
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
const counts = /GUARD_CALLS (\{[^\n]+\})/.exec(result.stderr);
assert.ok(counts, result.stderr);
assert.deepEqual(JSON.parse(counts[1]), { child: 0, lock: 0, runner: 0, fetch: 0 });
return result.stderr;
}

for (const mode of ['dry-run', 'reconcile']) {
test(`${mode} rejects unsupported and malformed runtimes before all external boundaries`, () => {
for (const version of [
'24.18.0',
'20.19.0',
'21.9.0',
'23.0.0',
'22.18.9',
'private-invalid-value',
]) {
const stderr = guardedChild([mode], version);
assert.match(stderr, /RETENTION_STOP: runtime-mismatch/);
assert.ok(!stderr.includes(version));
}
});

test(`${mode} accepts supported runtime with mocked provider and unchanged source checks`, async () => {
const world = standardWorld();
const lines = [];
await withRetentionRuntime(() =>
main([mode], makeDeps(world), { log: (line) => lines.push(line) }),
);
const summary = JSON.parse(
lines.find((line) => line.startsWith('RETENTION_SUMMARY ')).slice(18),
);
assert.equal(summary.stop, null);
assert.equal(summary.writeAttempts, mode === 'dry-run' ? 0 : world.writes.length);
assert.equal(world.released, mode === 'dry-run' ? 0 : 1);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
assert.ok(world.calls.some((call) => call.startsWith('vercel api')));
if (mode === 'reconcile') {
const expectedDeleted = ['dpl_prev12', 'dpl_prev11', 'dpl_prev2', 'dpl_prev1'];
const deploymentWrites = world.writes
.filter((write) => write.startsWith('remove dpl_'))
.map((write) => write.split(' ')[1]);
assert.ok(summary.writeAttempts > 0);
assert.deepEqual(summary.deleted, expectedDeleted);
assert.deepEqual(deploymentWrites, expectedDeleted);
}
});
}

test('the actual matrix runtime refuses before tools, locks and runner imports', () => {
let expectedStop = 'invalid-arguments';
try {
assertRuntime();
} catch (error) {
expectedStop = error.reason;
}
const stderr = guardedChild(['--execute']);
assert.ok(stderr.includes(`RETENTION_STOP: ${expectedStop}`));
});

test('supported runtime still rejects a modified source before provider calls or locks', async () => {
const world = standardWorld();
world.differs = true;
await assert.rejects(
withRetentionRuntime(() => main(['reconcile'], makeDeps(world), { log() {} })),
{ reason: 'runner-source-differs-from-main' },
);
assert.ok(world.calls.every((call) => call.startsWith('git ')));
assert.deepEqual(world.writes, []);
assert.equal(world.released, 0);
});
9 changes: 7 additions & 2 deletions tests/unit/tooling/vercelRetentionSafety.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
preview,
production,
standardWorld,
withRetentionRuntime,
} from './support/vercelRetentionFixture.mjs';

const OTHER = 'c'.repeat(40);
Expand Down Expand Up @@ -466,7 +467,9 @@ test('reconcile prints the whole manifest before its first write, then one summa
world.hooks.onWrite = () => {
seenAtFirstWrite ??= [...lines];
};
await main(['reconcile'], makeDeps(world), { log: (line) => lines.push(line) });
await withRetentionRuntime(() =>
main(['reconcile'], makeDeps(world), { log: (line) => lines.push(line) }),
);
assert.equal(seenAtFirstWrite.length, 8);
assert.ok(seenAtFirstWrite.every((line) => line.startsWith('RETENTION_MANIFEST ')));
assert.equal(lines.filter((line) => line.startsWith('RETENTION_SUMMARY')).length, 1);
Expand All @@ -475,7 +478,9 @@ test('reconcile prints the whole manifest before its first write, then one summa

test('a manifest row shows the state, target, age and alias hosts the gates looked at', async () => {
const lines = [];
await main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) });
await withRetentionRuntime(() =>
main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) }),
);
const row = lines.find((line) => line.includes('dpl_prev12 | SAFE'));
assert.match(row, /\| READY\/preview \| 1970-01-01T00:00:01\.012Z \|/);
assert.match(row, /aliases=worldscript-studio-git-fix-gone-qnbs-projects\.vercel\.app/);
Expand Down
Loading