mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Durable accounting needs repeatable regression gates and inspectable operational guidance. > - Broad suite success alone does not prove that crash recovery or critical financial invariants are tested. > - This pull request adds per-module coverage floors, mutation sentinels and an accounting operations guide. > - CI detects weakened accounting tests and operators can inspect failures without guessing. ## Linked Issues or Issue Description Refs #14548, #5598. Completes the verification for #14997 and #14999. Stack order: #14989 → #14991 → #14994 → #14997 → #14999 → #15001. Part 6 of 6. Review against `fix/costs-dashboard-accounting`. Merge authorized; landing in dependency order. ## What Changed - Add and typecheck focused PostgreSQL tests for crash recovery, concurrency, receipt replay, reporting and operator recovery, plus exact-money and provider-import tests. - Require saved passing results for all four SIGKILL cases and both historical migration cases; skipped or missing cases fail. The historical runtime fixture restores bigint and verifies an exact lifetime total beyond JavaScript’s safe integer range. - Require each of thirteen accounting service modules to meet its own coverage thresholds. - Kill deliberate mutations of receipt deduplication, company isolation, projection conservation and budget boundaries at their specifically marked assertions; reject setup errors, timeouts and unrelated failures. - Retain mutation and coverage evidence on failures and upload it from the trusted CI workflow. - Preserve frozen-only installs for canary onboarding, optional E2E, Storybook visual and live provider evals. Execute regressions that require stale locks to fail closed without registry resolution; the existing repository bot owns lockfile refreshes. - Add a scale benchmark and the final implementation/operations guide, with spec links introduced alongside the guide. - Exercise empty-turn recovery through the real usage translator with legacy/current receipts, complete/incomplete flags, malformed metadata and counter checks. Keep Cloud-auth fixture teardown hardening in this test gate. - Cover ACPX completeness through the real notification translator and driver, plus additive reservation/dispatch and exact daily-spend assertions. - Drain Cloud-auth browser route handlers before teardown. - Require frozen-only installation in the accounting CI lane and gate coverage execution on successful installation, so the provider cannot come from inline registry resolution. Other CI lanes keep their existing install policy. - Test fractional status-card and skill-test response costs, invalid monetary values and integer token counters. Include the additive accounting OpenAPI assertions. - Cover missing/malformed/mismatched billing secret designations, privileged caller roles and inactive or cross-company memberships. Document dedicated billing-secret setup. Include fractional and large status-card cost regressions. ## Verification - `pnpm test:accounting` checks 98% lines, 90% branches, 96% statements and 96% functions per service module, plus required migration/recovery evidence and mutation checks. - `node --test scripts/test-accounting-mutations.test.mjs scripts/verify-accounting-test-results.test.mjs` passed all 22 gate-harness scenarios. - Inspect `coverage/accounting/` for coverage and test/mutation evidence. The benchmark is an explicit operator command, not a timing threshold in CI. - All 30 release/workflow shard tests and actionlint pass. The new regression executes the actual accounting-lane install: a stale lock exits without any registry resolution; a current lock proceeds. It fails against the old fallback. Integrated-stack validation on master `c365a16e346d`: repository-wide typechecking and production build, token gates, 313 focused accounting tests, two historical migration tests, four required SIGKILL cases and four mutation sentinels pass. The thirteen accounting modules meet their individual coverage floors (aggregate 99.7% lines, 94.82% branches). CI fixture regressions pass 48 connection-card cases, both recovery release orders and the personal-connection browser workflow. Final consumer regressions pass all 28 Browser Use lifecycle, 42 email pipeline and 140 rendered issue-detail cases; local email verification used this checkout’s bundled skills to avoid an unrelated ancestor skills directory. Merged-source regressions pass AI connections (107), route authorization (23), worktree cloning (63) and ACPX execution (221). Additional checks during stack preparation passed native execution (565), heartbeat admission (60), OpenCode/checkpoint/failure-path (102), Codex runner drivers (316) and Costs UI (21). Runtime/connection (644), adapter/billing (86), all Codex adapter cases (509 passed, one skipped), dashboard (106), worktree-cloning (63), CLI cost (14) and gate-harness (48) tests pass. Release workflow tests (18) and actionlint pass; frozen-install shell regressions confirm current locks proceed and stale locks fail without retries. The repository lockfile bot must land the coverage dependency refresh before frozen-install workflows run. All three accounting browser workflows pass on the current merged tree; the Storybook build and populated provider/biller smoke check passed earlier in stack preparation. Landing validation against the six intervening master changes passes all 282 targeted UI tests, 18 workflow tests and repository-wide typechecking; Storybook host validation adds 19 passing cases and 18 live HTTP checks, with UI typechecking and token gates passing. After master consolidated Cursor support, the one cancellation overlap preserves both current-budget fencing and the new manual cancellation identities. All 642 affected heartbeat/cancellation/native/Cursor tests, repository-wide typechecking and production build pass after reinstalling the updated ACPX patch. The rebuilt final tree matches that combined source. Each PR’s standalone, latest-head CI status is recorded below. Latest-head CI has not yet passed. Latest-head Greptile sign-off is pending. A fresh Apex review will be requested for this authorized merge. ## Risks The focused gate adds CI time and requires PostgreSQL support. Trusted workflow changes take effect when this PR reaches master. Coverage does not claim to cover every heartbeat, adapter or browser path. Frozen-install jobs stop until the repository bot refreshes pnpm-lock.yaml for the new coverage dependency; no fallback runs unreviewed dependency code in those jobs. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code editing and test execution. The exact serving model ID and context window were not exposed to this 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 Co-authored-by: Paperclip <noreply@paperclip.ing>
496 lines
22 KiB
JavaScript
496 lines
22 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
"..",
|
|
);
|
|
|
|
function readWorkflow(name) {
|
|
return readFileSync(path.join(repoRoot, ".github/workflows", name), "utf8");
|
|
}
|
|
|
|
test("chaos verification isolates callers that verify the same source commit", () => {
|
|
const chaosWorkflow = readWorkflow("runner-chaos-evals.yml");
|
|
const group = chaosWorkflow.match(/^ group: (.+)$/m)?.[1];
|
|
assert.ok(group, "chaos verification must define its concurrency group");
|
|
|
|
// GitHub supplies the top-level caller's workflow name to reusable calls.
|
|
const resolveGroup = (caller, ref) => group
|
|
.replaceAll("${{ github.workflow }}", readWorkflow(caller).match(/^name: (.+)$/m)[1])
|
|
.replaceAll("${{ inputs.ref || github.ref }}", ref)
|
|
.toLowerCase();
|
|
const sha = "a".repeat(40);
|
|
const callers = ["cloud-readiness.yml", "release.yml", "runner-chaos-evals.yml"];
|
|
const groups = callers.map((caller) => resolveGroup(caller, sha));
|
|
assert.equal(new Set(groups).size, callers.length,
|
|
"Cloud readiness, Release, and standalone evals must not cancel each other");
|
|
assert.ok(groups.every((value) => !value.includes("${{")), "resolve every group input");
|
|
assert.notEqual(resolveGroup("cloud-readiness.yml", sha),
|
|
resolveGroup("cloud-readiness.yml", "b".repeat(40)), "different sources remain independent");
|
|
assert.match(chaosWorkflow, /cancel-in-progress: true/);
|
|
});
|
|
|
|
test("canary reuses exact-source proof while stable keeps full verification", () => {
|
|
const releaseWorkflow = readWorkflow("release.yml");
|
|
const canary = releaseWorkflow.split(" verify_canary:\n")[1].split("\n publish_canary:")[0];
|
|
assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/);
|
|
assert.match(canary, /actions: read/);
|
|
assert.match(canary, /ref: \$\{\{ github\.sha \}\}/);
|
|
assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
|
assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/);
|
|
assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/);
|
|
assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/);
|
|
// The stable lane is gated on the stable channel since the nightly lane
|
|
// was added; a `needs:` line (for example a preflight job) may sit between
|
|
// the gate and the delegation.
|
|
// The stable preflight resolves source_ref to an immutable SHA exactly
|
|
// once; verification must consume that pin, not re-resolve the ref.
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ needs\.preflight_stable\.outputs\.sha \}\}/,
|
|
);
|
|
assert.doesNotMatch(
|
|
releaseWorkflow,
|
|
/verify_(?:canary|stable):[\s\S]*?pnpm test:run(?:\n|$)/,
|
|
);
|
|
});
|
|
|
|
test("source proof requires every source check and does not wait on image publication", () => {
|
|
const readiness = readWorkflow("cloud-readiness.yml");
|
|
const proof = readiness.split(" source_verified:\n")[1];
|
|
assert.match(proof, /name: Cloud source verified v1/);
|
|
assert.match(proof, /needs: \[verify\]/);
|
|
assert.match(proof, /node --test scripts\/cloud-source-verification.test.mjs/);
|
|
assert.match(proof, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
|
assert.doesNotMatch(proof, /always\(\)|continue-on-error|needs:.*(?:image|artifacts)/);
|
|
assert.doesNotMatch(readiness, /^ (?:image|artifacts|ready):/m);
|
|
});
|
|
|
|
test("onboard smoke container binds beyond loopback so the mapped port is reachable", () => {
|
|
const dockerfile = readFileSync(
|
|
path.join(repoRoot, "docker/Dockerfile.onboard-smoke"),
|
|
"utf8",
|
|
);
|
|
|
|
// `onboard --yes` without an explicit --bind prefers trusted-local
|
|
// defaults and writes a loopback bind, which Docker port mapping cannot
|
|
// reach. The smoke container must pin a non-loopback preset.
|
|
assert.match(dockerfile, /onboard --yes --bind lan/);
|
|
});
|
|
|
|
test("promotion selection guards against sources that predate their channel tooling", () => {
|
|
const releaseWorkflow = readWorkflow("release.yml");
|
|
|
|
// Promotions run the source commit's release.sh, so selection must reject
|
|
// sources whose tooling does not know the target channel yet.
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly'/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly\|beta\|stable\)'/,
|
|
);
|
|
});
|
|
|
|
test("candidate-branch betas are validated and fully verified before publish", () => {
|
|
const releaseWorkflow = readWorkflow("release.yml");
|
|
|
|
// Candidate heads are new commits: selection must pin the naming
|
|
// convention and publication must be gated on full verification.
|
|
assert.match(releaseWorkflow, /candidate\/beta-\*\)/);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/verify_beta_candidate:\n\s+needs: select_beta\n\s+if: needs\.select_beta\.outputs\.mode == 'candidate'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/needs\.verify_beta_candidate\.result == 'success'/,
|
|
);
|
|
});
|
|
|
|
test("post-publish beta smoke survives the skipped candidate-verification ancestor", () => {
|
|
const releaseWorkflow = readWorkflow("release.yml");
|
|
|
|
// publish_beta's needs chain contains verify_beta_candidate, which is
|
|
// skipped on promote-mode betas. An `if:` without a status-check function
|
|
// gets an implicit success() that evaluates that chain transitively and
|
|
// silently skips the smoke. The condition must stay explicit.
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/smoke_beta:\n\s+needs: publish_beta\n\s+if: \$\{\{ !cancelled\(\) && needs\.publish_beta\.result == 'success' && !inputs\.dry_run \}\}/,
|
|
);
|
|
});
|
|
|
|
test("published canaries are gated by the exact-version onboarding browser smoke", () => {
|
|
const releaseWorkflow = readWorkflow("release.yml");
|
|
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/publish_canary:[\s\S]*?outputs:\n\s+canary_version: \$\{\{ steps\.canary_tag\.outputs\.version \}\}/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/smoke_canary_onboarding:\n\s+needs: publish_canary\n\s+if: needs\.publish_canary\.result == 'success'/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/PAPERCLIPAI_VERSION: \$\{\{ needs\.publish_canary\.outputs\.canary_version \}\}/,
|
|
);
|
|
assert.match(releaseWorkflow, /test:canary-onboarding-smoke/);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/smoke_canary_onboarding:[\s\S]*?uses: actions\/checkout@[0-9a-f]{40} # v7[\s\S]*?uses: pnpm\/action-setup@[0-9a-f]{40} # v6[\s\S]*?uses: actions\/setup-node@[0-9a-f]{40} # v7/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/smoke_canary_onboarding:[\s\S]*?Install test dependencies\n\s+run: pnpm install --frozen-lockfile/,
|
|
);
|
|
assert.doesNotMatch(
|
|
releaseWorkflow.match(
|
|
/smoke_canary_onboarding:[\s\S]*?(?=\n # ----- Nightly lane)/,
|
|
)?.[0] ?? "",
|
|
/cache: pnpm/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/name: Smoke exact published canary through onboarding\n\s+env:\n\s+PAPERCLIP_CANARY_SMOKE_SERVER_LOG: \$\{\{ runner\.temp \}\}\/canary-onboarding-server\.log/,
|
|
);
|
|
assert.match(
|
|
releaseWorkflow,
|
|
/smoke_canary_onboarding:[\s\S]*?uses: actions\/upload-artifact@[0-9a-f]{40} # v7/,
|
|
);
|
|
assert.match(releaseWorkflow, /canary-onboarding-server\.log/);
|
|
assert.match(releaseWorkflow, /tests\/canary-onboarding\/playwright-report/);
|
|
});
|
|
|
|
test("every lane's tag push degrades to recovery instructions when rejected", () => {
|
|
const releaseWorkflow = readWorkflow("release.yml");
|
|
|
|
// GITHUB_TOKEN may not create refs pointing at workflow-modifying commits
|
|
// from dispatch or scheduled runs; a rejected tag push after a successful
|
|
// npm publish must surface runbook recovery commands, not a bare error.
|
|
const occurrences = releaseWorkflow.match(/## Tag push rejected/g) ?? [];
|
|
assert.equal(
|
|
occurrences.length,
|
|
3,
|
|
"nightly, beta, and stable each carry the recovery summary",
|
|
);
|
|
});
|
|
|
|
test("release smoke workflow extends the container readiness budget for CI", () => {
|
|
const smokeWorkflow = readWorkflow("release-smoke.yml");
|
|
const harness = readFileSync(
|
|
path.join(repoRoot, "scripts/docker-onboard-smoke.sh"),
|
|
"utf8",
|
|
);
|
|
|
|
// CI containers cold-install paperclipai and embedded postgres, so the
|
|
// workflow must extend the harness's local-default readiness budget.
|
|
assert.match(smokeWorkflow, /SMOKE_READY_TIMEOUT_SECONDS=\d+/);
|
|
const ciBudget = Number(
|
|
smokeWorkflow.match(/SMOKE_READY_TIMEOUT_SECONDS=(\d+)/)[1],
|
|
);
|
|
assert.ok(
|
|
ciBudget >= 300,
|
|
`CI readiness budget ${ciBudget}s should be at least 300s`,
|
|
);
|
|
|
|
assert.match(
|
|
harness,
|
|
/SMOKE_READY_TIMEOUT_SECONDS="\$\{SMOKE_READY_TIMEOUT_SECONDS:-\d+\}"/,
|
|
);
|
|
assert.match(
|
|
harness,
|
|
/wait_for_http "\$PAPERCLIP_PUBLIC_URL\/api\/health" "\$SMOKE_READY_TIMEOUT_SECONDS" 1/,
|
|
);
|
|
});
|
|
|
|
test("release verify workflow covers the same split test surface as stable PR verification", () => {
|
|
const verifyWorkflow = readWorkflow("release-verify.yml");
|
|
|
|
assert.match(verifyWorkflow, /workflow_call:/);
|
|
assert.match(
|
|
verifyWorkflow,
|
|
/node \.\/scripts\/release-package-map\.mjs check/,
|
|
);
|
|
assert.match(verifyWorkflow, /pnpm -r typecheck/);
|
|
assert.match(verifyWorkflow, /pnpm build/);
|
|
const runnerScripts = JSON.parse(readFileSync(path.join(repoRoot, "packages/paperclip-runner/package.json"), "utf8")).scripts;
|
|
const runnerChecks = [...verifyWorkflow.matchAll(/^ checks: (.+)$/gm)]
|
|
.flatMap(([, checks]) => checks.split(" "));
|
|
assert.deepEqual(runnerChecks, runnerScripts["check:all"].split(" && ")
|
|
.map((command) => command.replace(/^pnpm run /, "")));
|
|
assert.match(verifyWorkflow, /pnpm --filter @paperclipai\/paperclip-runner "\$check"/);
|
|
assert.match(verifyWorkflow, /runner_workflow_evals:/);
|
|
assert.match(verifyWorkflow, /runner_chaos_evals:/);
|
|
assert.match(
|
|
verifyWorkflow,
|
|
/uses: \.\/\.github\/workflows\/runner-chaos-evals\.yml/,
|
|
);
|
|
assert.match(
|
|
verifyWorkflow,
|
|
/runner_workflow_evals:[\s\S]*?Install dependencies\n\s+run: pnpm install --no-frozen-lockfile[\s\S]*?Run deterministic Runner workflow scorer tests/,
|
|
);
|
|
assert.match(verifyWorkflow, /pnpm test:runner-workflow-evals/);
|
|
|
|
const buildJob = verifyWorkflow.match(/ build:\n[\s\S]*?(?=\n [A-Za-z0-9_-]+:|$)/)?.[0] ?? "";
|
|
assert.match(buildJob, /persist-credentials: false/);
|
|
assert.doesNotMatch(buildJob, /cache: pnpm/);
|
|
|
|
for (const group of ["general-server-without-chat", "general-chat", "general-workspaces-a", "general-workspaces-b"]) {
|
|
assert.match(verifyWorkflow, new RegExp(`group: ${group}`));
|
|
}
|
|
for (const [group, count] of [["general-server-without-chat", 10], ["general-chat", 3]]) {
|
|
const rows = [...verifyWorkflow.matchAll(new RegExp(`group: ${group}\\n\\s+group_label: [^\\n]+\\n\\s+shard_index: (\\d+)\\n\\s+shard_count: (\\d+)`, "g"))];
|
|
assert.deepEqual(rows.map((row) => [Number(row[1]), Number(row[2])]),
|
|
Array.from({ length: count }, (_, index) => [index, count]));
|
|
}
|
|
for (const shardIndex of [0, 1, 2, 3, 4]) {
|
|
assert.match(verifyWorkflow, new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`));
|
|
}
|
|
|
|
// workspaces-a splits with Vitest native --shard in pr.yml; release
|
|
// verification must keep the same two-shard coverage.
|
|
for (const shardIndex of [0, 1]) {
|
|
assert.match(
|
|
verifyWorkflow,
|
|
new RegExp(
|
|
`group: general-workspaces-a[\\s\\S]*?shard_index: ${shardIndex}\\n\\s+shard_count: 2`,
|
|
),
|
|
);
|
|
}
|
|
|
|
assert.match(verifyWorkflow, /pnpm test:run:general -- --group/);
|
|
assert.match(verifyWorkflow, /pnpm test:run:serialized -- --shard-index/);
|
|
});
|
|
|
|
test("Runner eval workflows pin actions and gate paid live execution", () => {
|
|
const actionPinWorkflows = [
|
|
readWorkflow("release-verify.yml"),
|
|
readWorkflow("runner-live-evals.yml"),
|
|
readWorkflow("runner-chaos-evals.yml"),
|
|
readWorkflow("runner-full-stack-e2e.yml"),
|
|
readWorkflow("e2e.yml"),
|
|
readWorkflow("runner-protocol-live-evals.yml"),
|
|
];
|
|
|
|
for (const workflow of actionPinWorkflows) {
|
|
const remoteUses = workflow
|
|
.split("\n")
|
|
.filter(
|
|
(line) =>
|
|
/^\s*(?:-\s*)?uses: /.test(line) && !line.includes("uses: ./"),
|
|
);
|
|
assert.ok(
|
|
remoteUses.length > 0,
|
|
"expected at least one remote action reference",
|
|
);
|
|
for (const line of remoteUses) {
|
|
assert.match(line, /uses: [^@\s]+@[0-9a-f]{40}(?:\s+# .+)?$/);
|
|
}
|
|
}
|
|
|
|
const liveWorkflow = actionPinWorkflows[1];
|
|
assert.match(liveWorkflow, /RUNNER_LIVE_EVALS_NIGHTLY_ENABLED == 'true'/);
|
|
assert.match(liveWorkflow, /REF: \$\{\{ github\.ref \}\}/);
|
|
assert.match(liveWorkflow, /refs\/heads\/\$DEFAULT_BRANCH/);
|
|
assert.match(
|
|
liveWorkflow,
|
|
/DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/,
|
|
);
|
|
assert.match(liveWorkflow, /RUNNER_E2E_ALLOWED_ACTOR_IDS/);
|
|
assert.match(liveWorkflow, /needs: authorize/);
|
|
assert.match(liveWorkflow, /environment:\n\s+name: runner-e2e-paid/);
|
|
assert.match(
|
|
liveWorkflow,
|
|
/OPENAI_API_KEY: \$\{\{ secrets\.OPENAI_API_KEY \}\}/,
|
|
);
|
|
|
|
const paidWorkflowNames = [
|
|
"e2e.yml",
|
|
"runner-full-stack-e2e.yml",
|
|
"runner-live-evals.yml",
|
|
"runner-protocol-live-evals.yml",
|
|
];
|
|
const paidWorkflowNameSet = new Set(paidWorkflowNames);
|
|
const providerSecretReference =
|
|
/secrets(?:\.(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|XAI_API_KEY|GROK_AUTH_JSON|DAYTONA_API_KEY)\b|\[['"](?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|XAI_API_KEY|GROK_AUTH_JSON|DAYTONA_API_KEY)['"]\])/g;
|
|
for (const name of readdirSync(path.join(repoRoot, ".github/workflows"))) {
|
|
if (!/\.ya?ml$/.test(name)) continue;
|
|
const workflow = readWorkflow(name);
|
|
if ([...workflow.matchAll(providerSecretReference)].length > 0) {
|
|
assert.ok(
|
|
paidWorkflowNameSet.has(name),
|
|
`${name} must not receive provider credentials`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const name of paidWorkflowNames) {
|
|
const workflow = readWorkflow(name);
|
|
const triggerHeader = workflow.slice(0, workflow.indexOf("\njobs:\n"));
|
|
assert.doesNotMatch(
|
|
triggerHeader,
|
|
/^\s{2}(?:pull_request|pull_request_target|push|workflow_call|workflow_run):/m,
|
|
);
|
|
assert.match(triggerHeader, /^\s{2}workflow_dispatch:/m);
|
|
assert.match(workflow, /^ authorize:/m);
|
|
|
|
const jobBlocks = workflow
|
|
.slice(workflow.indexOf("\njobs:\n") + "\njobs:\n".length)
|
|
.split(/\n(?= [A-Za-z0-9_-]+:\n)/);
|
|
const providerJobs = jobBlocks.filter(
|
|
(block) => [...block.matchAll(providerSecretReference)].length > 0,
|
|
);
|
|
assert.ok(providerJobs.length > 0, `${name} needs a provider-secret job`);
|
|
for (const block of providerJobs) {
|
|
assert.match(block, /\n environment:\n name: runner-e2e-paid\n/);
|
|
assert.match(
|
|
block,
|
|
/\n steps:(?: &[A-Za-z0-9_-]+)?\n(?:\s*\n)* - name: Reauthorize[^\n]*\n/,
|
|
`${name} must reauthorize as the first provider-job step`,
|
|
);
|
|
const reauthorize = block.indexOf(" - name: Reauthorize");
|
|
assert.ok(reauthorize > 0);
|
|
assert.ok(block.indexOf("actions/checkout@") > reauthorize);
|
|
assert.ok(block.search(providerSecretReference) > reauthorize);
|
|
assert.match(block, /github\.actor_id/);
|
|
assert.match(block, /github\.triggering_actor/);
|
|
assert.match(block, /RUNNER_E2E_ALLOWED_ACTOR_IDS/);
|
|
assert.match(block, /refs\/heads\/\$DEFAULT_BRANCH/);
|
|
assert.doesNotMatch(block, /^\s+cache: pnpm$/m);
|
|
}
|
|
}
|
|
|
|
const fullStackWorkflow = readWorkflow("runner-full-stack-e2e.yml");
|
|
for (const [secret, condition] of Object.entries({
|
|
OPENAI_API_KEY: "matrix.credentialName == 'OPENAI_API_KEY'",
|
|
ANTHROPIC_API_KEY: "matrix.credentialName == 'ANTHROPIC_API_KEY'",
|
|
OPENROUTER_API_KEY: "matrix.credentialName == 'OPENROUTER_API_KEY'",
|
|
DAYTONA_API_KEY: "matrix.environmentId == 'daytona'",
|
|
})) {
|
|
assert.ok(
|
|
fullStackWorkflow.includes(
|
|
`${secret}: \${{ ${condition} && secrets.${secret} || '' }}`,
|
|
),
|
|
`${secret} must be scoped to only the matrix cells that require it`,
|
|
);
|
|
}
|
|
const historyPublisher = fullStackWorkflow.slice(
|
|
fullStackWorkflow.indexOf(" publish_history:"),
|
|
fullStackWorkflow.indexOf(" pages:"),
|
|
);
|
|
assert.doesNotMatch(historyPublisher, /^\s+cache: pnpm$/m);
|
|
|
|
for (const name of [
|
|
"runner-full-stack-e2e.yml",
|
|
"runner-live-evals.yml",
|
|
"runner-protocol-live-evals.yml",
|
|
]) {
|
|
const workflow = readWorkflow(name);
|
|
const crons = [...workflow.matchAll(/cron:\s*"([^"]+)"/g)].map(
|
|
(match) => match[1],
|
|
);
|
|
assert.equal(crons.length, 1, `${name} must have one schedule`);
|
|
assert.match(crons[0], /^\d{1,2} \d{1,2} \* \* 0$/);
|
|
}
|
|
|
|
const chaosWorkflow = actionPinWorkflows[2];
|
|
const runnerBlock = chaosWorkflow.match(
|
|
/- name: Run Runner fault and replay suites[\s\S]*?run: \|([\s\S]*?)(?=\n\s+- name: Build server test dependencies)/,
|
|
)?.[1];
|
|
const serverBlock = chaosWorkflow.match(
|
|
/- name: Run server finalization and recovery suites[\s\S]*?run: \|([\s\S]*?)(?=\n\s+- name: Upload chaos eval bundle)/,
|
|
)?.[1];
|
|
assert.ok(runnerBlock, "expected Runner chaos test command");
|
|
assert.ok(serverBlock, "expected server chaos test command");
|
|
for (const [base, block] of [
|
|
[path.join(repoRoot, "packages/paperclip-runner"), runnerBlock],
|
|
[path.join(repoRoot, "server"), serverBlock],
|
|
]) {
|
|
const listedTestPaths =
|
|
block.match(/src\/[A-Za-z0-9_./-]+\.test\.ts/g) ?? [];
|
|
assert.ok(listedTestPaths.length > 0, "expected chaos workflow test paths");
|
|
assert.equal(
|
|
new Set(listedTestPaths).size,
|
|
listedTestPaths.length,
|
|
"chaos workflow test paths must be unique",
|
|
);
|
|
for (const testPath of listedTestPaths) {
|
|
assert.ok(
|
|
existsSync(path.join(base, testPath)),
|
|
`chaos workflow test path does not exist: ${testPath}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
test("direct Grok qualification installs the pinned binary and scopes the selected credential", () => {
|
|
const workflow = readWorkflow("runner-protocol-live-evals.yml");
|
|
assert.ok(workflow.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}"));
|
|
assert.ok(workflow.includes("if [ -f packages/paperclip-runner/scripts/provision-grok.mjs ]; then"));
|
|
assert.ok(workflow.indexOf("sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod"));
|
|
assert.ok(workflow.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}"));
|
|
assert.equal((workflow.match(/secrets\.GROK_AUTH_JSON/gu) ?? []).length, 1);
|
|
});
|
|
|
|
test("direct protocol concurrency override only lowers the configured ceiling", () => {
|
|
const workflow = readWorkflow("runner-protocol-live-evals.yml");
|
|
const start = workflow.indexOf(' if [ -n "${REQUESTED_MAX_PARALLEL:-}" ]; then');
|
|
const end = workflow.indexOf(" node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs catalog", start);
|
|
assert.ok(start > 0 && end > start);
|
|
const script = workflow.slice(start, end) + '\nprintf "%s" "$MAX_PARALLEL"\n';
|
|
for (const [requested, expected] of [["", "8"], ["2", "2"], ["8", "8"], ["1", null], ["9", null], ["0", null], ["-1", null], ["2.5", null], ["garbage", null], ["9999999999999999999999", null]]) {
|
|
const result = spawnSync("bash", ["-eu", "-c", script], {
|
|
env: { ...process.env, MAX_PARALLEL: "8", REQUESTED_MAX_PARALLEL: requested }, encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, expected === null ? 1 : 0, requested);
|
|
if (expected !== null) assert.equal(result.stdout, expected);
|
|
}
|
|
});
|
|
|
|
for (const workflow of ["release.yml", "e2e.yml", "storybook-visual.yml", "runner-live-evals.yml"]) {
|
|
test(`${workflow} keeps its frozen dependency install and fails closed on stale locks`, () => {
|
|
const text = readWorkflow(workflow);
|
|
const job = workflow === "release.yml" ? text.split(" smoke_canary_onboarding:\n")[1].split("\n # ----- Nightly lane")[0] : text;
|
|
const install = job.match(/(?:- name: Install (?:test )?dependencies\n\s+run: ([^\n]+)|- run: (pnpm install[^\n]*))/);
|
|
assert.ok(install, "source workflow must expose a dependency install step");
|
|
const script = install[1] ?? install[2];
|
|
for (const exitCode of [0, 17]) {
|
|
const run = spawnSync("bash", ["-euo", "pipefail", "-c", `
|
|
pnpm() { printf '%s\n' "$*"; return "$INSTALL_EXIT"; }
|
|
${script}
|
|
`], { encoding: "utf8", env: { ...process.env, INSTALL_EXIT: String(exitCode) } });
|
|
assert.equal(run.status, exitCode, run.stderr);
|
|
assert.equal(run.stdout.trim(), "install --frozen-lockfile", "never resolve new packages or retry after a frozen install failure");
|
|
}
|
|
});
|
|
}
|
|
|
|
test("the accounting CI lane installs only the reviewed lockfile and gates coverage on that install", () => {
|
|
const job = readWorkflow("pr-trusted.yml").split(" general_tests:\n")[1].split(" docker_context_integrity:\n")[0];
|
|
const install = job.split(" - name: Install dependencies\n")[1].split(" - name: Run grouped general test suites\n")[0];
|
|
const script = install.split(" run: |\n")[1];
|
|
assert.ok(script, "accounting lane must have an inspectable install command");
|
|
for (const exitCode of [0, 17]) {
|
|
const result = spawnSync("bash", ["-euo", "pipefail", "-c", `
|
|
pnpm() { printf '%s\\n' "$*"; return "$INSTALL_EXIT"; }
|
|
${script}
|
|
`], { encoding: "utf8", env: { ...process.env, ACCOUNTING_GATE: "true", INSTALL_EXIT: String(exitCode) } });
|
|
assert.equal(result.status, exitCode, result.stderr);
|
|
assert.equal(result.stdout.trim(), "install --frozen-lockfile", "a stale lock must not resolve executable coverage code inline");
|
|
}
|
|
assert.match(install, /id: install_dependencies/);
|
|
assert.match(install, /ACCOUNTING_GATE: \$\{\{ matrix\.group == 'general-server-without-chat' && matrix\.shard_index == 0 \}\}/);
|
|
const coverage = job.split(" - name: Verify accounting coverage and crash recovery\n")[1].split(" - name: Upload accounting coverage\n")[0];
|
|
assert.match(coverage, /steps\.install_dependencies\.outcome == 'success'/, "failed installs must never load the coverage provider");
|
|
});
|