mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Native Runner must bind each provider turn to the selected task, account, model, and instructions. > - Antigravity needs native ACP admission and physical process ownership across continuation. > - A saved conversation must not reuse an old process owner or silently switch its effective model. > - This pull request adds the native candidate behind exact operator admission. > - The default release gate remains closed while qualification is pending. ## Linked Issues or Issue Description Builds on merged #18187. Product account setup and the candidate producer are in dependent #18189. The repeatable browser journey is in #18190. Refs: #12503, #14938; those proposals use legacy CLI adapters. **Agent or provider** Google Antigravity ACP 1.4.3. **Why this adapter is useful** Users need to set up an Antigravity agent and run real work through their managed Google subscription. **How the agent is invoked** Native Paperclip Runner uses the pinned Antigravity ACP runtime. Exact operator admission is required while qualification remains pending. ## What Changed - Add native Antigravity ACP model and instruction admission, selected-account homes, and fenced refresh ownership. - Carry exact candidate authority from the control plane through Runner, Rust, the sidecar, and each current run. Clamp daemon lifetime to the absolute permission expiry, including replacement owners. - Preserve a rotated OAuth refresh token before later identity verification can fail, including failed or cancelled startup admission. Make private lease-close retries safe. - Bound native probe retirement and retry the exact original owner; do not delete credential homes before physical proof. - Preserve Gemini API-key recovery when shared Google subscription capability is added. - Resume the same tagged native conversation with fresh per-turn process ownership and physical native retirement. - Include native manifests in isolated Rust image stages, release cache keys, and dev rebuild detection. The workflow changes only extend existing cache input lists. - Keep current master provider behavior and append Grok 13, Pi 35, and Copilot 84 input identities. Preserve prior identities, including saved Copilot 82 and 83 runs. ## Verification - Runner TypeScript main checks, profile generators, Rust formatting, and offline workspace checks pass. The native backend integration target passes 28 tests. - The complete transport and cleanup group passes 387 tests without unhandled rejections. These checks use local scripted children and actual process and port ownership. - Saved-profile decoding, native-execution parsing, installation, and selected-credential controls pass 208 focused tests. All 109 pre-existing profile identity files remain byte-for-byte unchanged. Copilot, Grok, and shared TypeScript/Rust generation checks pass. - The final recovery fixture passes all seven cases. After the master rebase, the combined recovery and owned-Node-runtime group passes all 10 cases. Each expiry case sets one 45-second absolute grant before startup. It includes the deliberate six-second cold delay, four existing eight-second command budgets, and active-turn headroom. The test proves an active turn before expiry and then checks the daemon, owned process group, child processes, and all three native port fences. The 60-second test deadline does not renew provider authority. - The consolidated model-discovery and pending-release group passes 22 tests. Google credential compatibility and auth recovery pass 25 tests, including existing Gemini API-key recovery. The full product settings test group remains intact in #18189. - Earlier failures remain in their original CI runs and local records. They exposed the Copilot 82 decoder gap, an expired five-second fixture bootstrap ticket, and a fixture grant consumed before activation on loaded CI. Each repair has a separate result. Production ticket limits and admission behavior are unchanged by the final test commits. - An unrelated decision-expiry check failed in [the earlier CI run](https://github.com/paperclipai/paperclip/actions/runs/40941986698/job/123688668997). Its owning code is unchanged from the base. The unchanged suite passes 20 tests on isolated PostgreSQL. A controlled database rollback reproduces the earlier wake: clean startup schedules the 120-second decision expiry, while rollback reconciliation leaves a 30-second continuation-coalescing wake. The decision stays open, with no wake delivery or error. The fresh final-head server shard passes; the failed result is preserved. - Final native commit `ec6bc7e4d77f5ae0cc4946dbba6c662e95a87f5b` passed `ci / verify` and `ci / e2e`. Fresh Greptile and Paperclip reviews both score 5/5, with no open findings. Antigravity retains the recorded native `.5` closure and exact low-model profile. This PR does not claim global or cross-platform qualification. ## Risks - The new transport carries private managed credentials. Exact ownership, account generation, model, instructions, and native retirement are fail-closed. - Only local macOS ARM64 has a verified complete Antigravity runtime. Default Antigravity task execution remains disabled without current trusted operator admission. The Google server sign-in lane and production candidate producer ship in #18189; this native slice does not expose the subscription catalog choice. - Shared launch inputs change provider identities. New identities are append-only; existing recorded identities remain available. ## Model Used OpenAI Codex (GPT-6). The host did not expose the exact model revision or context limit. The model used tools for code edits, tests, and review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
606 lines
32 KiB
JavaScript
606 lines
32 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { CACHED_PATHS, HASHED_INPUTS, REQUIRED, RUNNER_PACKAGE, computeScope, markCurrent } from "../runner-release-crate-cache.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const script = path.join(repoRoot, "scripts/runner-release-crate-cache.mjs");
|
|
const readWorkflow = (name) => readFileSync(path.join(repoRoot, ".github/workflows", name), "utf8");
|
|
const prWorkflow = readWorkflow("pr-trusted.yml");
|
|
const releaseWorkflow = readWorkflow("release-verify.yml");
|
|
|
|
// Slice one job out of a workflow, from its two-space-indented key to the next.
|
|
function job(workflow, name) {
|
|
const start = workflow.indexOf(`\n ${name}:\n`);
|
|
assert.ok(start >= 0, `workflow is missing the ${name} job`);
|
|
const body = workflow.slice(start + 1);
|
|
const header = ` ${name}:\n`.length;
|
|
const next = body.slice(header).search(/\n [a-z0-9_-]+:\n/);
|
|
return next === -1 ? body : body.slice(0, header + next + 1);
|
|
}
|
|
|
|
// Slice one step out of a job by its name, up to the next step.
|
|
function step(body, name) {
|
|
const marker = ` - name: ${name}\n`;
|
|
const start = body.indexOf(marker);
|
|
assert.ok(start >= 0, `missing step: ${name}`);
|
|
const rest = body.slice(start + marker.length);
|
|
const next = rest.search(/\n - name: /);
|
|
return marker + (next === -1 ? rest : rest.slice(0, next + 1));
|
|
}
|
|
|
|
const RELEASE_DIR = `${RUNNER_PACKAGE}/runner/target/release`;
|
|
const KEY_STEP = "Compute the Runner release crate cache key";
|
|
const RESTORE_STEP = "Restore the Runner release crate (read only)";
|
|
const MARK_STEP = "Mark the restored Runner release crate current";
|
|
const LOOKUP_STEP = "Check for this push's Runner release crate";
|
|
const BUILD_STEP = "Build the Runner release crate for its cache";
|
|
const SAVE_STEP = "Save the Runner release crate";
|
|
const RUST_RESTORE_STEP = "Restore Runner Rust dependencies (read only)";
|
|
|
|
// Every PR job that builds the Runner release binary, and the command each
|
|
// one runs cargo through. The crate must be restored and marked before it.
|
|
const READERS = [
|
|
["typecheck_release_registry", "run: pnpm run typecheck:build-gaps"],
|
|
["verify_paperclip_runner", "run: pnpm --filter @paperclipai/paperclip-runner ${{ matrix.command }}"],
|
|
["build", "run: pnpm build"],
|
|
["canary_dry_run", "- name: Release canary dry run via release.sh internal build"],
|
|
];
|
|
|
|
const WRITER_GATE = "matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha";
|
|
|
|
const expectedPathBlock = ` path: |\n${CACHED_PATHS.map((entry) => ` ${entry}\n`).join("")}`;
|
|
const expectedKey = `key: runner-release-crate-v1-\${{ steps.runner_release_crate_key.outputs.scope }}-\${{ hashFiles(${HASHED_INPUTS.map((entry) => `'${entry}'`).join(", ")}) }}`;
|
|
|
|
function assertCacheStep(body, name, { lookupOnly = false } = {}) {
|
|
assert.match(body, /uses: actions\/cache\/(restore|save)@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6\.1\.0/, name);
|
|
assert.ok(body.includes(expectedPathBlock), `${name}: the cached path list must be exactly the script's CACHED_PATHS`);
|
|
assert.ok(body.includes(expectedKey), `${name}: the key must be built from the script's scope and HASHED_INPUTS`);
|
|
// An exact match or nothing: a prefix match would restore another
|
|
// revision's artifacts and the mark step would make cargo keep them.
|
|
assert.doesNotMatch(body, /restore-keys:/, name);
|
|
assert.equal(/lookup-only: true/.test(body), lookupOnly, name);
|
|
assert.doesNotMatch(body, /enableCrossOsArchive|fail-on-cache-miss: true/, name);
|
|
}
|
|
|
|
test("the script's path list and key inputs cover what the release build of the crate reads and writes", () => {
|
|
assert.deepEqual(CACHED_PATHS, [
|
|
`${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*`,
|
|
`${RELEASE_DIR}/deps/libpaperclip_runner_core-*`,
|
|
`${RELEASE_DIR}/deps/paperclip_runner_core-*.d`,
|
|
`${RELEASE_DIR}/deps/paperclip_runnerd-*`,
|
|
`${RELEASE_DIR}/paperclip-runnerd`,
|
|
`${RELEASE_DIR}/paperclip-runnerd.d`,
|
|
]);
|
|
assert.deepEqual(HASHED_INPUTS, [
|
|
`${RUNNER_PACKAGE}/rust-toolchain.toml`,
|
|
`${RUNNER_PACKAGE}/acpx-profiles.json`,
|
|
`${RUNNER_PACKAGE}/antigravity-distributions.json`,
|
|
`${RUNNER_PACKAGE}/runner/Cargo.toml`,
|
|
`${RUNNER_PACKAGE}/runner/Cargo.lock`,
|
|
`${RUNNER_PACKAGE}/runner/crates/**`,
|
|
`${RUNNER_PACKAGE}/protocol/**`,
|
|
`${RUNNER_PACKAGE}/test-fixtures/**`,
|
|
`${RUNNER_PACKAGE}/test/fixtures/**`,
|
|
"tests/runner-e2e/fixtures/**",
|
|
]);
|
|
for (const input of HASHED_INPUTS) {
|
|
const fixed = input.replace(/\/\*\*$/, "");
|
|
assert.ok(statSync(path.join(repoRoot, fixed)), `${input} must exist in the repository`);
|
|
}
|
|
// The stage step copies the uplifted binary; it has to be in the set.
|
|
const stage = readFileSync(path.join(repoRoot, RUNNER_PACKAGE, "scripts/stage-runner-binary.mjs"), "utf8");
|
|
assert.match(stage, /"runner", "target", "release", executable/);
|
|
assert.ok(CACHED_PATHS.includes(`${RELEASE_DIR}/paperclip-runnerd`));
|
|
});
|
|
|
|
// Every `include_str!` / `include_bytes!` in one Rust source, with the path
|
|
// each one names. Three argument forms are resolved: a string literal
|
|
// (relative to the source file), and `concat!(...)` of string literals,
|
|
// `env!("CARGO_MANIFEST_DIR")` (the directory of the nearest Cargo.toml) and
|
|
// macro metavariables (`$name`, which make the resolved path a directory
|
|
// prefix rather than a file). Anything else is reported as unsupported, so
|
|
// a new include form cannot slip past the guard by not matching a regex.
|
|
function crateIncludes(source, text) {
|
|
const includes = [];
|
|
for (const match of text.matchAll(/include_(?:str|bytes)!\s*\(/g)) {
|
|
const start = match.index + match[0].length;
|
|
let depth = 1;
|
|
let inString = false;
|
|
let end = start;
|
|
for (; end < text.length && depth > 0; end += 1) {
|
|
const ch = text[end];
|
|
if (inString) {
|
|
if (ch === "\\") end += 1;
|
|
else if (ch === '"') inString = false;
|
|
} else if (ch === '"') inString = true;
|
|
else if (ch === "(") depth += 1;
|
|
else if (ch === ")") depth -= 1;
|
|
}
|
|
assert.equal(depth, 0, `${source}: unbalanced include at offset ${match.index}`);
|
|
const argument = text.slice(start, end - 1).trim();
|
|
includes.push({ argument, ...resolveIncludeArgument(source, argument) });
|
|
}
|
|
return includes;
|
|
}
|
|
|
|
function manifestDir(source) {
|
|
let dir = path.dirname(source);
|
|
while (!statSync(path.join(dir, "Cargo.toml"), { throwIfNoEntry: false })?.isFile()) {
|
|
const parent = path.dirname(dir);
|
|
assert.notEqual(parent, dir, `${source}: no Cargo.toml above the source`);
|
|
dir = parent;
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function resolveIncludeArgument(source, argument) {
|
|
const literal = argument.match(/^"((?:[^"\\]|\\.)*)"$/);
|
|
if (literal) return { form: "literal", resolved: path.resolve(path.dirname(source), literal[1]), exact: true };
|
|
|
|
const concat = argument.match(/^concat!\s*\(([\s\S]*)\)$/);
|
|
assert.ok(concat, `${source}: unsupported include form: ${argument}`);
|
|
const parts = splitTopLevel(concat[1]);
|
|
// A relative path is joined to the source's directory; one that starts
|
|
// with the manifest directory is one string, since its next literal
|
|
// starts with the "/" that separates the two.
|
|
let base = `${path.dirname(source)}/`;
|
|
let text = "";
|
|
let exact = true;
|
|
let form = "concat";
|
|
for (const [index, part] of parts.entries()) {
|
|
const string = part.match(/^"((?:[^"\\]|\\.)*)"$/);
|
|
if (string) {
|
|
assert.ok(exact || !/(^|\/)\.\.(\/|$)/.test(string[1]), `${source}: a path segment after a metavariable must not climb out: ${argument}`);
|
|
if (exact) text += string[1];
|
|
continue;
|
|
}
|
|
if (part === 'env!("CARGO_MANIFEST_DIR")') {
|
|
assert.equal(index, 0, `${source}: env!("CARGO_MANIFEST_DIR") must be the first part of concat!: ${argument}`);
|
|
base = manifestDir(source);
|
|
form = "concat-manifest-dir";
|
|
continue;
|
|
}
|
|
if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(part)) {
|
|
// The fixed prefix before the first metavariable is the directory the
|
|
// macro's callers can reach; the suffix cannot climb out of it.
|
|
exact = false;
|
|
form = form === "concat-manifest-dir" ? form : "concat-metavariable";
|
|
continue;
|
|
}
|
|
assert.fail(`${source}: unsupported concat! part in include: ${part}`);
|
|
}
|
|
const joined = base + text;
|
|
const resolved = path.resolve(exact || text.endsWith("/") ? joined : path.dirname(joined));
|
|
return { form, resolved, exact };
|
|
}
|
|
|
|
// Split concat!'s arguments on the commas outside string literals.
|
|
function splitTopLevel(text) {
|
|
const parts = [];
|
|
let current = "";
|
|
let inString = false;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const ch = text[index];
|
|
if (inString) {
|
|
current += ch;
|
|
if (ch === "\\") current += text[++index] ?? "";
|
|
else if (ch === '"') inString = false;
|
|
} else if (ch === '"') {
|
|
inString = true;
|
|
current += ch;
|
|
} else if (ch === ",") {
|
|
parts.push(current);
|
|
current = "";
|
|
} else current += ch;
|
|
}
|
|
parts.push(current);
|
|
return parts.map((part) => part.trim()).filter(Boolean);
|
|
}
|
|
|
|
const underHashedInputs = (resolved) =>
|
|
HASHED_INPUTS.map((input) => path.join(repoRoot, input.replace(/\/\*\*$/, ""))).some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`));
|
|
|
|
test("the include parser resolves literals, concat! with the manifest directory and with metavariables, and rejects other forms", () => {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-includes-"));
|
|
try {
|
|
const crate = path.join(root, "crates/demo");
|
|
mkdirSync(path.join(crate, "src/nested"), { recursive: true });
|
|
mkdirSync(path.join(root, "protocol/fixtures"), { recursive: true });
|
|
writeFileSync(path.join(crate, "Cargo.toml"), '[package]\nname = "demo"\n');
|
|
writeFileSync(path.join(root, "protocol/schema.json"), "{}");
|
|
const source = path.join(crate, "src/nested/mod.rs");
|
|
const text = [
|
|
'let a = include_str!("../../../../protocol/schema.json");',
|
|
'let b = include_bytes!(\n "../../../../protocol/schema.json"\n);',
|
|
'let c = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../protocol/", "schema.json"));',
|
|
'let d = include_str!(concat!("../../../../protocol/fixtures/", $fixture, ".json"));',
|
|
'let e = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../protocol/fixtures/", $golden, ".summary.json"));',
|
|
'let f = include_str!("a (paren) in the name.json");',
|
|
].join("\n");
|
|
const includes = crateIncludes(source, text);
|
|
assert.deepEqual(
|
|
includes.map(({ form, resolved, exact }) => [form, path.relative(root, resolved), exact]),
|
|
[
|
|
["literal", "protocol/schema.json", true],
|
|
["literal", "protocol/schema.json", true],
|
|
["concat-manifest-dir", "protocol/schema.json", true],
|
|
["concat-metavariable", "protocol/fixtures", false],
|
|
["concat-manifest-dir", "protocol/fixtures", false],
|
|
["literal", "crates/demo/src/nested/a (paren) in the name.json", true],
|
|
],
|
|
);
|
|
|
|
const rejected = [
|
|
'include_str!(concat!("../protocol/", $x, "/../../../outside.json"))',
|
|
'include_str!(concat!("../protocol/", env!("CARGO_MANIFEST_DIR")))',
|
|
'include_str!(concat!(env!("OTHER_DIR"), "/schema.json"))',
|
|
'include_str!(r"../protocol/schema.json")',
|
|
'include_str!(path_from_a_function())',
|
|
'include_str!(concat!("../protocol/", some_macro!("x")))',
|
|
];
|
|
for (const argument of rejected) {
|
|
assert.throws(() => crateIncludes(source, argument), /unsupported|must not climb out|must be the first part/, argument);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("every file the crate includes by path sits under a hashed input, so an edit to it changes the key", () => {
|
|
const crateRoot = path.join(repoRoot, RUNNER_PACKAGE, "runner/crates");
|
|
const sources = [];
|
|
(function walk(dir) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(full);
|
|
else if (entry.name.endsWith(".rs")) sources.push(full);
|
|
}
|
|
})(crateRoot);
|
|
assert.ok(sources.length > 10, "the crate sources must be found");
|
|
const forms = new Set();
|
|
let includes = 0;
|
|
for (const source of sources) {
|
|
for (const { argument, form, resolved, exact } of crateIncludes(source, readFileSync(source, "utf8"))) {
|
|
includes += 1;
|
|
forms.add(form);
|
|
const where = `${path.relative(repoRoot, source)} includes ${argument.replace(/\s+/g, " ")}`;
|
|
assert.ok(underHashedInputs(resolved), `${where}, which resolves to ${path.relative(repoRoot, resolved)}, outside every HASHED_INPUTS pattern`);
|
|
const info = statSync(resolved, { throwIfNoEntry: false });
|
|
assert.ok(exact ? info?.isFile() : info?.isDirectory(), `${where}: ${path.relative(repoRoot, resolved)} must exist`);
|
|
}
|
|
}
|
|
assert.ok(includes > 0, "the crate includes protocol files by path; the guard must see them");
|
|
// The crate uses every supported form today, so none of the parser's
|
|
// branches is exercised only by the synthetic test above.
|
|
assert.deepEqual([...forms].sort(), ["concat-manifest-dir", "concat-metavariable", "literal"]);
|
|
});
|
|
|
|
test("isolated Docker Rust stages preserve the layout of embedded schema and identity inputs", () => {
|
|
const crateRoot = path.join(repoRoot, RUNNER_PACKAGE, "runner/crates");
|
|
const sources = [];
|
|
(function walk(dir) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(full);
|
|
else if (entry.name.endsWith(".rs")) sources.push(full);
|
|
}
|
|
})(crateRoot);
|
|
for (const [dockerfile, stage] of [["Dockerfile", "runner-build"], ["docker/daytona-runner/Dockerfile", "runnerd-build"]]) {
|
|
const docker = readFileSync(path.join(repoRoot, dockerfile), "utf8");
|
|
const body = docker.split(/^FROM .+ AS /m).find(part => part.startsWith(`${stage}\n`));
|
|
assert.ok(body, `${dockerfile}: missing ${stage}`);
|
|
const workdir = /^WORKDIR (.+)$/m.exec(body)?.[1];
|
|
assert.ok(workdir?.startsWith("/"), `${dockerfile}: an absolute stage working directory is required`);
|
|
const copies = [...body.matchAll(/^COPY (?!-)(.+)$/gm)].flatMap(([, instruction]) => {
|
|
const args = instruction.trim().split(/\s+/);
|
|
const destination = path.posix.resolve(workdir, args.pop());
|
|
return args.map(source => {
|
|
const absolute = path.join(repoRoot, source);
|
|
return { source: absolute, destination: statSync(absolute).isDirectory() ? destination : path.posix.join(destination, path.basename(source)) };
|
|
});
|
|
});
|
|
const copiedPath = source => {
|
|
const copy = copies.find(copy => source === copy.source || source.startsWith(`${copy.source}${path.sep}`));
|
|
return copy ? path.posix.join(copy.destination, path.relative(copy.source, source)) : null;
|
|
};
|
|
let checked = 0;
|
|
for (const source of sources) {
|
|
for (const { resolved } of crateIncludes(source, readFileSync(source, "utf8"))) {
|
|
// Cargo test-only fixture trees are intentionally absent from production
|
|
// Docker stages. Their cache coverage is checked by the preceding test.
|
|
if (/\/(?:test-fixtures|test\/fixtures|tests\/runner-e2e\/fixtures)(?:\/|$)/.test(resolved)) continue;
|
|
const stagedSource = copiedPath(source);
|
|
assert.ok(stagedSource, `${dockerfile}: crate source was not copied: ${path.relative(repoRoot, source)}`);
|
|
const expected = path.posix.resolve(path.posix.dirname(stagedSource), path.relative(path.dirname(source), resolved));
|
|
assert.equal(copiedPath(resolved), expected, `${dockerfile}: include input ${path.relative(repoRoot, resolved)} must retain its crate-relative path`);
|
|
checked++;
|
|
}
|
|
}
|
|
assert.ok(checked > 0, `${dockerfile}: the guard must see real Rust include paths`);
|
|
}
|
|
});
|
|
|
|
test("every PR reader restores the crate after the dependency cache and marks it before building", () => {
|
|
for (const [name, buildCommand] of READERS) {
|
|
const body = job(prWorkflow, name);
|
|
const positions = [RUST_RESTORE_STEP, KEY_STEP, RESTORE_STEP, MARK_STEP].map((stepName) => body.indexOf(` - name: ${stepName}\n`));
|
|
assert.ok(positions.every((position) => position >= 0), `${name}: a crate cache step is missing`);
|
|
assert.deepEqual([...positions].sort((a, b) => a - b), positions, `${name}: the crate steps must follow the dependency restore in order`);
|
|
const build = body.indexOf(buildCommand);
|
|
assert.ok(build > positions.at(-1), `${name}: the crate must be marked before ${buildCommand}`);
|
|
|
|
const key = step(body, KEY_STEP);
|
|
assert.match(key, /id: runner_release_crate_key\n/, name);
|
|
assert.match(key, /run: echo "scope=\$\(node scripts\/runner-release-crate-cache\.mjs scope\)" >> "\$GITHUB_OUTPUT"/, name);
|
|
|
|
const restore = step(body, RESTORE_STEP);
|
|
assert.match(restore, /id: runner_release_crate\n/, name);
|
|
assertCacheStep(restore, `${name} restore`);
|
|
assert.doesNotMatch(restore, /actions\/cache\/save/, name);
|
|
|
|
const mark = step(body, MARK_STEP);
|
|
assert.match(mark, /if: \$\{\{ steps\.runner_release_crate\.outputs\.cache-hit == 'true' \}\}/, name);
|
|
assert.match(mark, /run: node scripts\/runner-release-crate-cache\.mjs mark-current/, name);
|
|
}
|
|
// No other job restores or saves the crate.
|
|
const total = prWorkflow.match(/- name: Restore the Runner release crate \(read only\)\n/g)?.length ?? 0;
|
|
assert.equal(total, READERS.length);
|
|
assert.doesNotMatch(prWorkflow, /actions\/cache\/save@/);
|
|
});
|
|
|
|
test("the Verify Paperclip Runner job restores the crate only in the lanes that build it", () => {
|
|
const body = job(prWorkflow, "verify_paperclip_runner");
|
|
const lanes = [...body.matchAll(/- lane_label: (.+)\n\s+command: (.+)\n(\s+release_crate: (true)\n)?/g)].map(([, label, , , flag]) => [label, flag === "true"]);
|
|
assert.deepEqual(lanes, [
|
|
["static checks", false],
|
|
["rust", true],
|
|
["vitest 1/2", false],
|
|
["vitest 2/2", true],
|
|
]);
|
|
for (const stepName of [KEY_STEP, RESTORE_STEP]) {
|
|
assert.match(step(body, stepName), /if: \$\{\{ matrix\.release_crate == true \}\}/, stepName);
|
|
}
|
|
// A lane that skipped the restore has no cache-hit output, so the mark
|
|
// step's own condition is enough there.
|
|
assert.match(step(body, MARK_STEP), /if: \$\{\{ steps\.runner_release_crate\.outputs\.cache-hit == 'true' \}\}/);
|
|
for (const [name] of READERS.filter(([name]) => name !== "verify_paperclip_runner")) {
|
|
for (const stepName of [KEY_STEP, RESTORE_STEP]) {
|
|
assert.doesNotMatch(step(job(prWorkflow, name), stepName), /^\s+if:/m, `${name}: ${stepName} is unconditional`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("only the master Rust lane writes the crate, after a lookup, with the readers' exact path list and key", () => {
|
|
const body = job(releaseWorkflow, "verify_paperclip_runner");
|
|
const positions = ["Verify Paperclip Runner", "Warm debug dependencies for the shared Runner cache", KEY_STEP, LOOKUP_STEP, BUILD_STEP, SAVE_STEP].map((stepName) => body.indexOf(` - name: ${stepName}\n`));
|
|
assert.ok(positions.every((position) => position >= 0), "a writer step is missing");
|
|
assert.deepEqual([...positions].sort((a, b) => a - b), positions, "the writer steps must follow the checks and the debug warm-up, in order");
|
|
|
|
for (const stepName of [KEY_STEP, LOOKUP_STEP]) {
|
|
assert.match(step(body, stepName), new RegExp(`if: \\$\\{\\{ ${WRITER_GATE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\}\\}`), stepName);
|
|
}
|
|
for (const stepName of [BUILD_STEP, SAVE_STEP]) {
|
|
assert.match(
|
|
step(body, stepName),
|
|
new RegExp(`if: \\$\\{\\{ ${WRITER_GATE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && steps\\.runner_release_crate\\.outputs\\.cache-hit != 'true' \\}\\}`),
|
|
stepName,
|
|
);
|
|
}
|
|
assert.match(step(body, KEY_STEP), /run: echo "scope=\$\(node scripts\/runner-release-crate-cache\.mjs scope\)" >> "\$GITHUB_OUTPUT"/);
|
|
|
|
const lookup = step(body, LOOKUP_STEP);
|
|
assert.match(lookup, /id: runner_release_crate\n/);
|
|
assert.match(lookup, /uses: actions\/cache\/restore@/);
|
|
assertCacheStep(lookup, "writer lookup", { lookupOnly: true });
|
|
|
|
// The saved artifacts come from the readers' exact command, on a crate
|
|
// cleaned of the test-profile variants the lane's checks built.
|
|
const build = step(body, BUILD_STEP);
|
|
assert.match(build, /working-directory: packages\/paperclip-runner\n/);
|
|
assert.match(build, /set -euo pipefail\n\s+cargo clean --release --manifest-path runner\/Cargo\.toml -p paperclip-runner-core\n\s+pnpm build:binary\n/);
|
|
const runnerScripts = JSON.parse(readFileSync(path.join(repoRoot, RUNNER_PACKAGE, "package.json"), "utf8")).scripts;
|
|
assert.match(runnerScripts["build:binary"], /^cargo build --release --manifest-path runner\/Cargo\.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && /);
|
|
|
|
const save = step(body, SAVE_STEP);
|
|
assert.match(save, /uses: actions\/cache\/save@/);
|
|
assertCacheStep(save, "writer save");
|
|
|
|
// The dependency cache's own writer gate is the same expression.
|
|
assert.match(body, new RegExp(`save-if: \\$\\{\\{ ${WRITER_GATE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\}\\}`));
|
|
assert.equal((releaseWorkflow.match(/actions\/cache\/save@/g) ?? []).length, 1);
|
|
});
|
|
|
|
test("the scope names the platform, the compiler and the checkout root, and survives a missing rustc", () => {
|
|
const a = computeScope({ workspace: "/home/runner/_work/paperclip/paperclip", rustcVersion: "1.97.1-abcdef123-x86_64-unknown-linux-gnu" });
|
|
const b = computeScope({ workspace: "/home/runner/work/paperclip/paperclip", rustcVersion: "1.97.1-abcdef123-x86_64-unknown-linux-gnu" });
|
|
const c = computeScope({ workspace: "/home/runner/_work/paperclip/paperclip", rustcVersion: "1.97.2-0123456ab-x86_64-unknown-linux-gnu" });
|
|
assert.notEqual(a, b, "a different checkout root must select a different entry");
|
|
assert.notEqual(a, c, "a different compiler must select a different entry");
|
|
assert.equal(a, computeScope({ workspace: "/home/runner/_work/paperclip/paperclip/", rustcVersion: "1.97.1-abcdef123-x86_64-unknown-linux-gnu" }));
|
|
assert.match(a, new RegExp(`^${os.platform()}-${os.arch()}-1\\.97\\.1-abcdef123-x86_64-unknown-linux-gnu-[0-9a-f]{12}$`));
|
|
assert.doesNotMatch(computeScope({ rustcVersion: "weird version/with spaces" }), /[^A-Za-z0-9._-]/);
|
|
|
|
// The CLI reads rustc from PATH; a stand-in proves which fields it reads,
|
|
// and an empty PATH proves a missing compiler still yields a usable key.
|
|
const bin = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-rustc-"));
|
|
try {
|
|
const stub = path.join(bin, "rustc");
|
|
writeFileSync(stub, '#!/bin/sh\nprintf "rustc 1.97.1 (8bab26f4f 2026-06-30)\\nbinary: rustc\\ncommit-hash: 8bab26f4f0123456789abcdef\\ncommit-date: 2026-06-30\\nhost: x86_64-unknown-linux-gnu\\nrelease: 1.97.1\\nLLVM version: 21.1.2\\n"\n');
|
|
chmodSync(stub, 0o755);
|
|
const withStub = execFileSync(process.execPath, [script, "scope"], { encoding: "utf8", env: { ...process.env, PATH: bin, GITHUB_WORKSPACE: "/home/runner/_work/paperclip/paperclip" } }).trim();
|
|
assert.equal(withStub, computeScope({ workspace: "/home/runner/_work/paperclip/paperclip", rustcVersion: "1.97.1-8bab26f4f-x86_64-unknown-linux-gnu" }));
|
|
const without = execFileSync(process.execPath, [script, "scope"], { encoding: "utf8", env: { ...process.env, PATH: mkdtempSync(path.join(os.tmpdir(), "empty-path-")), GITHUB_WORKSPACE: "/home/runner/_work/paperclip/paperclip" } }).trim();
|
|
assert.match(without, /-no-rustc-/);
|
|
} finally {
|
|
rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The tree cargo leaves behind for the crate's release lib and bin units,
|
|
// with the mtimes GitHub's cache restores: older than a fresh checkout.
|
|
function layRestoredCrate(root) {
|
|
const release = path.join(root, RELEASE_DIR);
|
|
const lib = path.join(release, ".fingerprint/paperclip-runner-core-0b6c4654c2710743");
|
|
const bin = path.join(release, ".fingerprint/paperclip-runner-core-46cce78dbc4840c2");
|
|
mkdirSync(path.join(release, "deps"), { recursive: true });
|
|
mkdirSync(lib, { recursive: true });
|
|
mkdirSync(bin, { recursive: true });
|
|
const files = {
|
|
[path.join(lib, "lib-paperclip_runner_core")]: "0123456789abcdef",
|
|
[path.join(lib, "lib-paperclip_runner_core.json")]: "{}",
|
|
[path.join(lib, "dep-lib-paperclip_runner_core")]: "dep",
|
|
[path.join(lib, "invoked.timestamp")]: "t",
|
|
[path.join(bin, "bin-paperclip-runnerd")]: "fedcba9876543210",
|
|
[path.join(bin, "bin-paperclip-runnerd.json")]: "{}",
|
|
[path.join(bin, "dep-bin-paperclip-runnerd")]: "dep",
|
|
[path.join(release, "deps/libpaperclip_runner_core-0b6c4654c2710743.rlib")]: "rlib",
|
|
[path.join(release, "deps/libpaperclip_runner_core-0b6c4654c2710743.rmeta")]: "rmeta",
|
|
[path.join(release, "deps/paperclip_runner_core-0b6c4654c2710743.d")]: "d",
|
|
[path.join(release, "deps/paperclip_runnerd-46cce78dbc4840c2")]: "ELF",
|
|
[path.join(release, "deps/paperclip_runnerd-46cce78dbc4840c2.d")]: "d",
|
|
[path.join(release, "paperclip-runnerd")]: "ELF",
|
|
[path.join(release, "paperclip-runnerd.d")]: "d",
|
|
// A dependency's artifacts sit beside the crate's and must not be touched.
|
|
[path.join(release, "deps/libserde-1234567890abcdef.rlib")]: "dep rlib",
|
|
};
|
|
const old = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
for (const [file, content] of Object.entries(files)) {
|
|
writeFileSync(file, content);
|
|
utimesSync(file, old, old);
|
|
}
|
|
for (const dir of [lib, bin, path.join(release, "deps"), path.join(release, ".fingerprint"), release]) utimesSync(dir, old, old);
|
|
return { release, files: Object.keys(files), old };
|
|
}
|
|
|
|
test("mark-current makes every restored artifact newer than the checkout and leaves dependencies alone", () => {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-"));
|
|
try {
|
|
const { release, files, old } = layRestoredCrate(root);
|
|
const checkout = path.join(root, RUNNER_PACKAGE, "runner/crates/runner-core/src/lib.rs");
|
|
mkdirSync(path.dirname(checkout), { recursive: true });
|
|
writeFileSync(checkout, "// checked out after the artifacts were built\n");
|
|
const checkedOutAt = statSync(checkout).mtimeMs;
|
|
|
|
const result = markCurrent({ root, now: new Date(checkedOutAt + 5000) });
|
|
assert.equal(result.action, "marked");
|
|
assert.equal(result.files, 14);
|
|
assert.equal(result.bytes, files.filter((file) => !file.includes("libserde")).reduce((sum, file) => sum + statSync(file).size, 0));
|
|
|
|
for (const file of files) {
|
|
const mtime = statSync(file).mtimeMs;
|
|
if (file.includes("libserde")) assert.equal(Math.floor(mtime / 1000), Math.floor(old.getTime() / 1000), "a dependency artifact must keep its mtime");
|
|
else assert.ok(mtime > checkedOutAt, `${path.relative(release, file)} must be newer than the checkout`);
|
|
}
|
|
for (const dir of [".fingerprint/paperclip-runner-core-0b6c4654c2710743", ".fingerprint/paperclip-runner-core-46cce78dbc4840c2"]) {
|
|
assert.ok(statSync(path.join(release, dir)).mtimeMs > checkedOutAt, `${dir} must be newer than the checkout`);
|
|
}
|
|
assert.ok(statSync(checkout).mtimeMs === checkedOutAt, "sources are never touched");
|
|
|
|
// The CLI prints the same result as one line of JSON for the job log.
|
|
const printed = JSON.parse(execFileSync(process.execPath, [script, "mark-current"], { encoding: "utf8", env: { ...process.env, GITHUB_WORKSPACE: root } }));
|
|
assert.equal(printed.schema, "paperclip.runner-release-crate.v1");
|
|
assert.equal(printed.action, "marked");
|
|
assert.equal(printed.files, 14);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// One file per REQUIRED entry, as cargo 1.97.1 lays them out for the crate's
|
|
// release lib and bin units. Measured: without any one of these cargo
|
|
// recompiles the unit (the whole crate for a lib file, a relink for a bin
|
|
// file); without any of the files in NOT_NEEDED it reports the crate fresh
|
|
// and recreates the uplifted binary from the one under deps.
|
|
const REQUIRED_FILES = {
|
|
"the lib fingerprint hash": ".fingerprint/paperclip-runner-core-0b6c4654c2710743/lib-paperclip_runner_core",
|
|
"the lib dep-info": ".fingerprint/paperclip-runner-core-0b6c4654c2710743/dep-lib-paperclip_runner_core",
|
|
"the lib rlib": "deps/libpaperclip_runner_core-0b6c4654c2710743.rlib",
|
|
"the lib rmeta": "deps/libpaperclip_runner_core-0b6c4654c2710743.rmeta",
|
|
"the bin fingerprint hash": ".fingerprint/paperclip-runner-core-46cce78dbc4840c2/bin-paperclip-runnerd",
|
|
"the bin dep-info": ".fingerprint/paperclip-runner-core-46cce78dbc4840c2/dep-bin-paperclip-runnerd",
|
|
"the bin under deps": "deps/paperclip_runnerd-46cce78dbc4840c2",
|
|
};
|
|
const NOT_NEEDED = [
|
|
".fingerprint/paperclip-runner-core-0b6c4654c2710743/lib-paperclip_runner_core.json",
|
|
".fingerprint/paperclip-runner-core-0b6c4654c2710743/invoked.timestamp",
|
|
".fingerprint/paperclip-runner-core-46cce78dbc4840c2/bin-paperclip-runnerd.json",
|
|
"deps/paperclip_runner_core-0b6c4654c2710743.d",
|
|
"deps/paperclip_runnerd-46cce78dbc4840c2.d",
|
|
"paperclip-runnerd",
|
|
"paperclip-runnerd.d",
|
|
];
|
|
|
|
test("mark-current discards a restore that lacks any file cargo needs for either unit, naming the file, so cargo sees a plain miss", () => {
|
|
assert.deepEqual(REQUIRED.map(({ label }) => label), Object.keys(REQUIRED_FILES), "every REQUIRED entry has a missing-file case");
|
|
for (const [label, file] of Object.entries(REQUIRED_FILES)) {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-"));
|
|
try {
|
|
const { release } = layRestoredCrate(root);
|
|
rmSync(path.join(release, file));
|
|
const result = markCurrent({ root });
|
|
assert.equal(result.action, "discarded", `${file} missing must discard the restore`);
|
|
assert.equal(result.reason, `incomplete: ${label} is missing`);
|
|
assert.ok(result.removed > 0, label);
|
|
assert.deepEqual(readdirSync(path.join(release, ".fingerprint")), [], label);
|
|
assert.deepEqual(readdirSync(path.join(release, "deps")), ["libserde-1234567890abcdef.rlib"], `${label}: only the crate's own artifacts are removed`);
|
|
assert.deepEqual(readdirSync(release).sort(), [".fingerprint", "deps"], label);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// The bin's dep-info alone does not stand in for the bin: the glob that
|
|
// finds the binary under deps must not be satisfied by the `.d` beside it.
|
|
const root = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-"));
|
|
try {
|
|
const { release } = layRestoredCrate(root);
|
|
rmSync(path.join(release, "deps/paperclip_runnerd-46cce78dbc4840c2"));
|
|
assert.ok(statSync(path.join(release, "deps/paperclip_runnerd-46cce78dbc4840c2.d")).isFile());
|
|
assert.equal(markCurrent({ root }).reason, "incomplete: the bin under deps is missing");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
|
|
// A file cargo neither reads nor checks for may be absent without costing
|
|
// a rebuild, so its absence must not cost one here either.
|
|
for (const file of NOT_NEEDED) {
|
|
const intact = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-"));
|
|
try {
|
|
const { release } = layRestoredCrate(intact);
|
|
rmSync(path.join(release, file));
|
|
const result = markCurrent({ root: intact });
|
|
assert.equal(result.action, "marked", `${file} missing must not discard the restore`);
|
|
assert.equal(result.files, 13, file);
|
|
} finally {
|
|
rmSync(intact, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// Nothing restored at all is reported the same way and exits cleanly.
|
|
const empty = mkdtempSync(path.join(os.tmpdir(), "runner-release-crate-empty-"));
|
|
try {
|
|
const printed = JSON.parse(execFileSync(process.execPath, [script, "mark-current"], { encoding: "utf8", env: { ...process.env, GITHUB_WORKSPACE: empty } }));
|
|
assert.equal(printed.action, "discarded");
|
|
assert.equal(printed.reason, "incomplete: the lib fingerprint hash is missing");
|
|
assert.equal(printed.removed, 0);
|
|
} finally {
|
|
rmSync(empty, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("the CLI prints the lists the workflows embed", () => {
|
|
const paths = execFileSync(process.execPath, [script, "paths"], { encoding: "utf8" }).trim().split("\n");
|
|
assert.deepEqual(paths, CACHED_PATHS);
|
|
const inputs = execFileSync(process.execPath, [script, "hashed-inputs"], { encoding: "utf8" }).trim().split("\n");
|
|
assert.deepEqual(inputs, HASHED_INPUTS);
|
|
const usage = spawnSync(process.execPath, [script, "nonsense"], { encoding: "utf8" });
|
|
assert.equal(usage.status, 2);
|
|
assert.match(usage.stderr, /usage: runner-release-crate-cache\.mjs/);
|
|
});
|