mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Its trusted PR workflow builds the Runner's Rust binary in five
jobs: Typecheck + Release Registry, Build, Canary Dry Run, and the
`rust` and `vitest 2/2` lanes of Verify Paperclip Runner. The `Build`
job is the slowest check in almost every green run (461s, 489s and 515s
in runs 38180574768, 38182717052 and 38183149920 on 2026-10-11).
> - Each of those jobs restores master's `release-runner-v2` dependency
cache. That cache holds only third-party crates. `rust-cache` removes
workspace crates when it saves, and a fresh checkout invalidates cargo's
mtime fingerprints for workspace crates in any case. So cargo compiles
`paperclip-runner-core` in release mode from scratch on every run. The
job logs show one `Compiling` line and 28s to 49s for it.
> - 18 of the 239 commits on `master` in the seven days before this
change touched the crate's inputs. The other 221 compiled the same
sources to the same binary again.
> - This pull request adds a second cache that holds only that crate's
release artifacts (about 70 MB). The key is made from the compiler, the
checkout root and a hash of every input the build reads. The master Rust
lane writes it. A pull request whose merge tree has byte-identical
inputs restores it and marks the artifacts newer than the checkout, so
cargo keeps them. Every other pull request misses and compiles as
before.
> - The benefit is about 28s to 49s less on `Build`, which is the run's
critical path, and the same on Typecheck, Canary Dry Run and two Runner
lanes, for about nine of ten pull requests.
## Linked Issues or Issue Description
No GitHub issue exists for this. The description below follows the
enhancement template.
**What existing behavior does this improve?**
The time the trusted PR workflow spends to compile the Runner's own
crate on every run.
**Subsystem affected**
CI / build
**Current behavior**
Every PR job that builds the Runner binary compiles
`paperclip-runner-core` in release mode from scratch. The compile takes
28s to 49s on the `runs-on` fleet (one `Compiling paperclip-runner-core`
line, then `Finished release profile` in runs 38169436532 to 38183149920
on 2026-10-11). The shared Rust cache cannot help: it holds only
third-party crates, and a fresh checkout gives every source a new mtime,
which makes cargo discard the crate's fingerprints.
**Proposed behavior**
The master Rust lane in `release-verify.yml` saves the crate's release
artifacts under a key made from the compiler, the checkout root and a
hash of every input the release build reads. The PR jobs that build the
binary restore that entry read-only. On a hit, a script sets the mtime
of every restored artifact to now, so cargo sees every source as older
than the build and keeps the artifacts. `pnpm build` then prints
`Finished release profile` in under a second for that crate. On a miss,
cargo compiles the crate exactly as before.
**Reason and benefit**
About 28s to 49s off `Build`, the slowest check in almost every green
run, and the same off the Typecheck, Canary Dry Run and two Verify
Paperclip Runner lanes. The saving applies to every pull request whose
merge tree does not change the crate's inputs, which was 221 of 239
`master` commits in the week before this change.
**Breaking changes**
None. The checks are unchanged. A miss or an eviction costs the compile
time it costs today and nothing else.
**Additional context**
This is a workflow change. `pr.yml` calls `pr-trusted.yml@master`, so
this pull request's own run uses the current reader steps, and the
writer runs only on a master push. The saving becomes visible on the
first PR runs after the merge and after the next master push has written
the entry.
## What Changed
- `scripts/runner-release-crate-cache.mjs` (new): the one place that
defines the cached path list (`CACHED_PATHS`), the hashed inputs
(`HASHED_INPUTS`) and the files a restore must contain (`REQUIRED`). The
`scope` subcommand prints the platform, the `rustc --version --verbose`
release, commit and host, and a hash of the checkout root. The
`mark-current` subcommand sets the mtime of every restored artifact to
now and prints a `paperclip.runner-release-crate.v1` JSON line with the
file count and bytes. If the restored set lacks any of the seven files
cargo's freshness check reads or checks for (the fingerprint hash and
dep-info of each unit, the lib's rlib and rmeta, the binary under
`deps`), it removes the restored files, prints `action: discarded`
naming the missing file, and exits 0, so cargo compiles as on a miss.
- `.github/workflows/pr-trusted.yml`: after the `release-runner-v2`
restore in `typecheck_release_registry`, `build` and `canary_dry_run`,
and in the `rust` and `vitest 2/2` lanes of `verify_paperclip_runner`
(new matrix key `release_crate: true`), three steps: compute the key
scope, `actions/cache/restore` the crate under
`runner-release-crate-v1-<scope>-<hashFiles(...)>` with no
`restore-keys`, and run `mark-current` when `cache-hit == 'true'`. The
`hashFiles` list covers the toolchain pin, the manifests and lockfile,
the crate sources, the protocol tree and the three fixture trees the
test modules include (two under the Runner package and
`tests/runner-e2e/fixtures` at the repository root).
- `.github/workflows/release-verify.yml`: in the Rust lane of an
own-repository master push, after the debug warm-up: compute the key
scope, look the key up (`lookup-only: true`), and on a miss run `cargo
clean --release -p paperclip-runner-core` and `pnpm build:binary` (the
exact command the readers run, so the entry carries no test-profile
variants), then `actions/cache/save` the same path list under the same
key.
- `scripts/__tests__/runner-release-crate-cache.test.mjs` (new, 10
tests, in `test:release-registry`): the path list and hashed inputs are
the expected ones and exist; every `include_str!` and `include_bytes!`
in the crate sources resolves under a hashed input, whether its argument
is a literal or a `concat!` of literals, `env!("CARGO_MANIFEST_DIR")`
and macro metavariables, and any other form fails the test by name (this
guard found the `test/fixtures` tree during development and the
`tests/runner-e2e/fixtures` tree in review); the parser itself is tested
on a synthetic crate for each supported form and six rejected ones;
every PR reader restores after the dependency cache and marks before its
build command with the script's exact path list and key and no
`restore-keys`; the Verify lanes are gated by `release_crate`; the
writer runs after the checks with the lookup, the clean and build, and
the save under the readers' exact path list and key; the scope changes
with the compiler and the checkout root and survives a missing `rustc`;
`mark-current` makes every artifact newer than the checkout, leaves a
dependency's artifacts alone, discards a restore missing any one of the
seven required files (one case each, checking the named reason), and
still marks a restore missing any one of the seven files cargo does not
need.
## Review fixes
Greptile's two findings on the first commit were both valid and are
fixed in the second commit:
- **Incomplete caches looked usable.** `REQUIRED` checked four files,
but cargo's freshness check needs seven. Measured with cargo 1.97.1 on
the real crate (restore, touch the artifacts, delete one file, build):
without the lib's fingerprint hash, dep-info, rlib or rmeta it
recompiles the crate (24s to 27s); without the bin's fingerprint hash,
dep-info or the binary under `deps` it relinks (1.5s); without either
`.json` fingerprint record, either rustc `.d` file, the uplifted binary
or its `.d` it reports the crate fresh in 0.15s and recreates the
uplifted binary from `deps`, and the staging step then copies it.
`REQUIRED` now lists exactly the seven files, the glob for the binary
under `deps` excludes the `.d` beside it, and the test removes each of
the seven in turn (discarded, naming it) and each of the other seven in
turn (still marked).
- **Composed include paths escaped the guard.** The regex matched only
`include_str!("literal")` and skipped `include_str!(concat!(...))`,
which the crate uses four times (30 of 34 includes were checked). The
guard now parses every include argument and resolves the `concat!`
forms; one of them, in `provider_events.rs`, reads
`tests/runner-e2e/fixtures/native-completion/terminal-tool-carrier.json`
at the repository root, outside every hashed input. It is test-only, so
today's release binary does not read it, but the key is meant to cover
every include whatever its build, so `tests/runner-e2e/fixtures/**` (9
files, 80 KB) joins `HASHED_INPUTS` and the six key embeddings in the
two workflows. With the first commit's input list, the new guard fails
on exactly that include.
- `doc/RELEASE-AUTOMATION-SETUP.md`: a "Runner release crate cache"
section under the Runner verification dependency cache, with the key,
the writer, the readers and the trust boundary.
- `package.json`: the new test file in `test:release-registry`.
## Verification
- `node --test scripts/__tests__/runner-release-crate-cache.test.mjs`:
10 tests pass, three consecutive runs.
- The second commit, with the real toolchain on the real crate (`rustc
1.97.1`, cold build 2m20s, the crate's artifacts 74 MB): every source
touched to a fresh-checkout mtime and the archived artifacts restored,
cargo compiles (`Compiling paperclip-runner-core`, 24.8s and 25.1s, the
two controls); restored and marked by `mark-current`, `Finished release
profile in 0.16s` with no `Compiling`, and the same on a second build.
Then, for each of the fourteen files cargo wrote for the two units, one
at a time: restored, marked and that file deleted, cargo recompiles
without any of the seven `REQUIRED` files and reports fresh without any
of the other seven; restored with that file deleted and run through
`mark-current`, the seven required ones print `action: discarded` naming
the file and the others print `marked`. A restore holding only the seven
required files builds fresh in 0.15s, cargo recreates the uplifted
binary, and `stage-runner-binary.mjs` copies it into `dist/bin`.
- `node --test '.github/scripts/tests/*.test.mjs'`: 378 of 381 pass. The
3 failures are in `docker-canary-promotion.test.mjs` and fail the same
way on an unmodified `master` checkout in this sandbox (the stand-in
docker writes no `calls.jsonl`).
- `node --test scripts/__tests__/release-verify-workflow.test.mjs
scripts/__tests__/e2e-shard.test.mjs
scripts/__tests__/run-vitest-stable-shard.test.mjs`: 99 of 101 pass; the
2 failures spawn `pnpm`, which is not on this sandbox's PATH, and fail
the same way on `master`.
- Both workflows parse as YAML. No job has a duplicate step name or step
id.
- The mechanism, with the real toolchain (`rustc 1.97.1`, the package's
pin) on a copy of the crate: a cold `cargo build --release -p
paperclip-runner-core --bin paperclip-runnerd` (2m23s here). Then the
artifact set was archived with `tar`, removed, every source re-copied so
each had a new mtime, and the set restored:
- restored, not marked: `Compiling paperclip-runner-core`, 24.6s (the
control; this is what CI does today);
- restored and marked by `mark-current`: `Finished release profile in
0.15s`, no `Compiling`; a second build is the same;
- one Rust source edited after a marked restore: `Compiling`, 24.0s;
- one protocol schema edited after a marked restore: `Compiling`, 23.9s
(the protocol files enter cargo's dep-info as absolute paths and are
hashed into the key);
- the rlib removed from a restored set: `mark-current` prints `action:
discarded, reason: incomplete: the lib rlib is missing` and removes the
rest, and cargo compiles.
- Not run here: the workflow steps themselves. `pr.yml` pins
`pr-trusted.yml@master`, so this pull request's own `Build` job runs the
current reader steps, and the writer runs only on a master push. The
first PR runs after the merge and after the next master push show the
`Restore the Runner release crate` step's result and the
`paperclip.runner-release-crate.v1` line in their `Build` logs.
## Risks
- Correctness: a reader can only restore artifacts that master built
from inputs byte-identical to its own merge tree, because every input
the build reads is in the key, and the key has no prefix match. A pull
request cannot write. The trust boundary is unchanged: master writes, a
pull request restores master's entry.
- An input the build reads that is outside the hashed patterns would let
a stale artifact pass. The release dep-info lists the crate sources and
the protocol schemas and fixtures; all are hashed. The guard test
resolves every `include_str!` and `include_bytes!` in the crate, literal
or `concat!`, and fails if one lands outside the hashed inputs or uses a
form it cannot resolve, so a new include form cannot slip past it by not
matching a regex.
- GitHub-hosted runners check out under `/home/runner/work`, the
`runs-on` fleets under `/home/runner/_work`. Cargo records the included
protocol files as absolute paths, so an entry from one root is useless
under the other. The checkout root is in the key, so the other root
misses instead of restoring something cargo then discards.
- A cache that does not keep mtimes on restore changes nothing: the
artifacts are then already newer than the checkout, and `mark-current`
sets them to now in any case.
- Cost: one new entry of about 70 MB per distinct input set, written
only by the master Rust lane on a miss, plus one `cargo clean` and
`build:binary` (about 40s to 50s) on that lane when the inputs changed.
The readers pay one restore of about 70 MB (two to three seconds at the
fleet's observed restore speed) and a sub-second `mark-current`.
- An incomplete entry (a writer that saved a partial set) is discarded
by `mark-current` and costs one compile, the same as a miss.
## Model Used
- Claude (Anthropic), model id `claude-fable-5-1` (Claude Fable 5.1),
extended thinking, with tool use (shell, file editing, GitHub CLI, a
local Rust toolchain for the experiment) in a Paperclip agent session.
## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
559 lines
30 KiB
JavaScript
559 lines
30 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}/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("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/);
|
|
});
|