mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Merge current master lifecycle and CI fixes into Claude behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/claude-runner-protocol: fix(grok): avoid routine wakes for proactive admission (#16422) feat(ci): profile full CODEOWNER PR suites in Honeycomb (#16378) test: bound command snapshot forwarding checks (#16421) fix(agents): report actionable lifecycle failure steps (#16418)
This commit is contained in:
commit
bd3ceab2b2
50 files changed
+1630
-60
No files matched your search
@@ -1,6 +1,7 @@
|
||||
# Replace @cryppadotta if a different maintainer or team should own release infrastructure.
|
||||
|
||||
.github/** @cryppadotta @devinfoley @nickyleach @forgottendev
|
||||
scripts/ci-profile/** @cryppadotta @devinfoley @nickyleach @forgottendev
|
||||
scripts/release*.sh @cryppadotta @devinfoley @nickyleach @forgottendev
|
||||
scripts/release-*.mjs @cryppadotta @devinfoley @nickyleach @forgottendev
|
||||
scripts/create-github-release.sh @cryppadotta @devinfoley @nickyleach @forgottendev
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildTrace } from "../../../scripts/ci-profile/trace.mjs";
|
||||
import { sendEvents } from "../../../scripts/ci-profile/export.mjs";
|
||||
import { associatedProfilePr, ciOwners, trustedOwnerIds } from "../../../scripts/ci-profile/authorize.mjs";
|
||||
|
||||
const at = (seconds) => new Date(1_800_000_000_000 + seconds * 1000).toISOString();
|
||||
const run = {
|
||||
id: 42, run_attempt: 1, run_number: 7, status: "completed", conclusion: "failure",
|
||||
repository: { id: 1170821064, full_name: "paperclipai/paperclip" }, head_repository: { id: 99 },
|
||||
actor: { id: 123, type: "User" }, triggering_actor: { id: 123, type: "User" },
|
||||
head_sha: "abc", head_branch: "branch", created_at: at(0), run_started_at: at(0),
|
||||
html_url: "https://github.com/paperclipai/paperclip/actions/runs/42",
|
||||
};
|
||||
const job = (id, name, start, end) => ({
|
||||
id, run_id: 42, run_attempt: 1, name: `ci / ${name}`, status: "completed",
|
||||
conclusion: "success", created_at: at(1), started_at: at(start), completed_at: at(end),
|
||||
labels: ["ubuntu-latest"], steps: [
|
||||
{ number: 1, name: "Initialize CI profile", started_at: at(start), completed_at: at(start + 1), conclusion: "success" },
|
||||
{ number: 2, name: "Tests", started_at: at(start + 1), completed_at: at(end), conclusion: "success" },
|
||||
],
|
||||
});
|
||||
const jobs = [job(1, "first", 2, 12), job(2, "second", 3, 20)];
|
||||
const record = (fields) => ({ version: 1, runId: "42", attempt: "1", invocation: "invocation", job: "first", framework: "vitest", ...fields });
|
||||
|
||||
test("one wall-clock trace includes parallel jobs, wait, and every step", () => {
|
||||
const trace = buildTrace(run, jobs);
|
||||
assert.equal(trace.wallMs, 20000);
|
||||
assert.equal(trace.events[0].data.duration_ms, 20000);
|
||||
assert.equal(trace.events.filter((event) => event.data["ci.kind"] === "job").length, 2);
|
||||
assert.equal(trace.events.filter((event) => event.data["ci.kind"] === "step").length, 4);
|
||||
assert.equal(trace.events.filter((event) => event.data["ci.kind"] === "wait").length, 2);
|
||||
assert.equal(new Set(trace.events.map((event) => event.data["trace.trace_id"])).size, 1);
|
||||
assert.equal(trace.events.find((event) => event.data.name === "ci / second").data["ci.finishes_last"], true);
|
||||
assert.equal(trace.events[0].data.error, true);
|
||||
});
|
||||
|
||||
test("file/test hierarchy keeps runner timestamps and rejects unrelated artifact data", () => {
|
||||
const records = [
|
||||
record({ kind: "job" }), record({ kind: "job", job: "second" }),
|
||||
record({ kind: "start" }), record({ kind: "end" }),
|
||||
record({ kind: "file", id: "file", name: "a.test.ts", file: "a.test.ts", start: Date.parse(at(4)), duration: 6000, status: "passed" }),
|
||||
record({ kind: "test", id: "case", parent: "file", name: "runs", file: "a.test.ts", start: Date.parse(at(5)), duration: 100, status: "passed", secret: "must not leave artifact" }),
|
||||
record({ kind: "test", id: "foreign", runId: "41", name: "wrong run" }),
|
||||
];
|
||||
const trace = buildTrace(run, jobs, records);
|
||||
const file = trace.events.find((event) => event.data.name === "a.test.ts");
|
||||
const entry = trace.events.find((event) => event.data.name === "runs");
|
||||
assert.equal(entry.time, at(5));
|
||||
assert.equal(entry.data["trace.parent_id"], file.data["trace.span_id"]);
|
||||
assert.equal(file.data["trace.parent_id"], trace.events.find((event) => event.data.name === "Tests").data["trace.span_id"]);
|
||||
assert.equal(trace.events.some((event) => event.data.name === "wrong run"), false);
|
||||
assert.doesNotMatch(JSON.stringify(trace), /must not leave artifact/);
|
||||
assert.equal(trace.events[0].data["ci.profile_complete"], false);
|
||||
assert.match(trace.warnings.join(), /1 invalid/);
|
||||
});
|
||||
|
||||
test("short files belong to the latest containing step despite second-precision boundaries", () => {
|
||||
const records = [record({ kind: "file", id: "file", name: "short.test.ts", file: "short.test.ts",
|
||||
start: Date.parse(at(3)) + 100, duration: 50, status: "passed" })];
|
||||
const trace = buildTrace(run, jobs, records);
|
||||
const file = trace.events.find((event) => event.data.name === "short.test.ts");
|
||||
const step = trace.events.find((event) => event.data.name === "Tests");
|
||||
assert.equal(file.data["trace.parent_id"], step.data["trace.span_id"]);
|
||||
});
|
||||
|
||||
test("cancelled reporters and missing shards remain explicit", () => {
|
||||
const trace = buildTrace({ ...run, conclusion: "cancelled" }, jobs, [record({ kind: "job" }), record({ kind: "start" })]);
|
||||
assert.equal(trace.events[0].data["ci.profile_complete"], false);
|
||||
assert.match(trace.warnings.join(), /Missing timing artifacts for 1 jobs/);
|
||||
assert.match(trace.warnings.join(), /1 test invocations/);
|
||||
});
|
||||
|
||||
test("a completely absent reporter invocation cannot hide behind a successful sibling", () => {
|
||||
const records = [
|
||||
record({ kind: "job" }), record({ kind: "job", job: "second" }),
|
||||
record({ kind: "expected" }), record({ kind: "start" }), record({ kind: "end" }),
|
||||
record({ kind: "file", id: "file", name: "a.test.ts", file: "a.test.ts", start: Date.parse(at(4)), duration: 100, status: "passed" }),
|
||||
record({ kind: "expected", invocation: "reporter-never-loaded" }),
|
||||
];
|
||||
const trace = buildTrace(run, jobs, records);
|
||||
assert.equal(trace.events[0].data["ci.profile_complete"], false);
|
||||
assert.equal(trace.events[0].data["ci.expected_invocations"], 2);
|
||||
assert.equal(trace.events[0].data["ci.reported_invocations"], 1);
|
||||
assert.match(trace.warnings.join(), /1 test invocations/);
|
||||
});
|
||||
|
||||
test("coverage is conservative after failures and does not merge framework invocation IDs", () => {
|
||||
const records = [
|
||||
record({ kind: "job" }), record({ kind: "job", job: "second" }),
|
||||
record({ kind: "expected" }), record({ kind: "start" }), record({ kind: "end" }),
|
||||
];
|
||||
assert.equal(buildTrace({ ...run, conclusion: "success" }, jobs, records).events[0].data["ci.profile_complete"], true);
|
||||
assert.equal(buildTrace(run, jobs, records).events[0].data["ci.profile_complete"], false);
|
||||
const trace = buildTrace({ ...run, conclusion: "success" }, jobs, [
|
||||
...records, record({ framework: "node", kind: "expected" }),
|
||||
]);
|
||||
assert.equal(trace.events[0].data["ci.profile_complete"], false);
|
||||
assert.equal(trace.events[0].data["ci.expected_invocations"], 2);
|
||||
assert.equal(trace.events[0].data["ci.reported_invocations"], 1);
|
||||
});
|
||||
|
||||
test("trusted policy commands work with both current and pre-profile checkouts", () => {
|
||||
const source = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
|
||||
const block = source.split(" - name: Select compatible Node test command\n")[1].split("\n - name:")[0];
|
||||
const script = block.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "ci-profile-legacy-"));
|
||||
try {
|
||||
writeFileSync(path.join(directory, "probe.test.cjs"), 'require("node:test")("old checkout works", () => {});');
|
||||
for (const current of [false, true]) {
|
||||
if (current) {
|
||||
mkdirSync(path.join(directory, "scripts"));
|
||||
symlinkSync(fileURLToPath(new URL("../../../scripts/ci-profile", import.meta.url)), path.join(directory, "scripts/ci-profile"));
|
||||
}
|
||||
const env = { ...process.env, GITHUB_ENV: path.join(directory, "env"), PAPERCLIP_CI_PROFILE: "false" };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const result = spawnSync("bash", ["-c", `${script}\nsource "$GITHUB_ENV"\nnode "$CI_NODE_TEST" probe.test.cjs`], {
|
||||
cwd: directory, env, encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(readFileSync(env.GITHUB_ENV, "utf8"), current ? /CI_NODE_TEST=scripts\/ci-profile\/node-test.mjs/ : /CI_NODE_TEST=--test/);
|
||||
}
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("reruns use separate IDs and clocks and cannot mix job attempts", () => {
|
||||
const rerun = { ...run, run_attempt: 2, run_started_at: at(2) };
|
||||
assert.throws(() => buildTrace(rerun, jobs), /another run or attempt/);
|
||||
const trace = buildTrace(rerun, jobs.map((entry) => ({ ...entry, run_attempt: 2 })));
|
||||
assert.equal(trace.wallMs, 18000);
|
||||
assert.notEqual(trace.traceId, buildTrace(run, jobs).traceId);
|
||||
assert.equal(trace.events[0].data["ci.partial_rerun"], true);
|
||||
assert.throws(() => buildTrace({ ...run, status: "in_progress" }, jobs), /complete/);
|
||||
});
|
||||
|
||||
test("partial reruns exclude old jobs that GitHub relabels with the new attempt number", () => {
|
||||
const rerun = { ...run, run_attempt: 2, run_started_at: at(30) };
|
||||
const inventory = [
|
||||
{ ...job(1, "carried build", 2, 12), run_attempt: 2 },
|
||||
{ ...job(3, "retried tests", 32, 50), run_attempt: 2, created_at: at(31) },
|
||||
];
|
||||
const trace = buildTrace(rerun, inventory);
|
||||
assert.equal(trace.wallMs, 20000);
|
||||
assert.equal(trace.jobCount, 1);
|
||||
assert.equal(trace.events[0].data["ci.carried_job_count"], 1);
|
||||
assert.equal(trace.events.some((event) => event.data.name === "ci / carried build"), false);
|
||||
assert.equal(trace.events[0].data["ci.profile_complete"], false);
|
||||
assert.match(trace.warnings.join(), /1 completed jobs carried over/);
|
||||
assert.throws(() => buildTrace(rerun, inventory.slice(0, 1)), /No jobs executed/);
|
||||
});
|
||||
|
||||
test("label alone cannot authorize outsiders, bots, stale revisions or outsider reruns", () => {
|
||||
const pr = { user: { id: 123, type: "User" }, base: { repo: run.repository }, head: { repo: { id: 99 }, sha: "abc" }, labels: [{ name: "profile-ci" }] };
|
||||
assert.equal(associatedProfilePr(run, [pr], [123]), pr);
|
||||
for (const changed of [
|
||||
{ ...pr, labels: [] },
|
||||
{ ...pr, head: { ...pr.head, sha: "next" } },
|
||||
{ ...pr, head: { ...pr.head, repo: { id: 100 } } },
|
||||
{ ...pr, user: { id: 999, type: "User" } },
|
||||
{ ...pr, user: { id: 123, type: "Bot" } },
|
||||
{ ...pr, base: { repo: { ...run.repository, id: 100 } } },
|
||||
]) assert.equal(associatedProfilePr(run, [changed], [123]), undefined);
|
||||
assert.equal(associatedProfilePr({ ...run, actor: { id: 999, type: "User" } }, [pr], [123]), undefined);
|
||||
assert.equal(associatedProfilePr({ ...run, triggering_actor: { id: 999, type: "User" } }, [pr], [123]), undefined);
|
||||
assert.equal(associatedProfilePr({ ...run, repository: { ...run.repository, id: 100 } }, [pr], [123]), undefined);
|
||||
assert.equal(associatedProfilePr(run, [pr], []), undefined);
|
||||
});
|
||||
|
||||
test("profiling uses only infrastructure CODEOWNERS resolved at trusted master", () => {
|
||||
const source = '.github/** @owner @second\ndoc/** @outsider\n';
|
||||
assert.deepEqual(ciOwners(source), ["owner", "second"]);
|
||||
for (const invalid of ['', '.github/**', '.github/** @org/team', '.github/** @owner\n/.github/** @other']) {
|
||||
assert.throws(() => ciOwners(invalid), /CODEOWNERS/);
|
||||
}
|
||||
const endpoints = [];
|
||||
const sha = "a".repeat(40);
|
||||
assert.deepEqual(trustedOwnerIds((endpoint) => {
|
||||
endpoints.push(endpoint);
|
||||
if (endpoint.endsWith('git/ref/heads/master')) return { object: { sha } };
|
||||
if (endpoint.endsWith(`CODEOWNERS?ref=${sha}`)) return { encoding: 'base64', content: Buffer.from(source).toString('base64') };
|
||||
return { id: endpoint === '/users/owner' ? 123 : 456, type: 'User' };
|
||||
}), [123, 456]);
|
||||
assert.deepEqual(endpoints, [
|
||||
'/repos/paperclipai/paperclip/git/ref/heads/master',
|
||||
`/repos/paperclipai/paperclip/contents/.github/CODEOWNERS?ref=${sha}`, '/users/owner', '/users/second',
|
||||
]);
|
||||
});
|
||||
|
||||
test("export checks per-event ingestion results, including partial failures", async () => {
|
||||
const events = Array.from({ length: 501 }, () => ({ data: {} }));
|
||||
const sizes = [];
|
||||
await sendEvents(events, "test-only", "paperclip-ci", async (url, options) => {
|
||||
assert.equal(url, "https://api.honeycomb.io/1/batch/paperclip-ci");
|
||||
assert.equal(options.redirect, "error");
|
||||
const size = JSON.parse(options.body).length;
|
||||
sizes.push(size);
|
||||
return { ok: true, json: async () => Array.from({ length: size }, () => ({ status: 202 })) };
|
||||
});
|
||||
assert.deepEqual(sizes, [500, 1]);
|
||||
await assert.rejects(sendEvents([events[0]], "test-only", "paperclip-ci", async () => ({ ok: true, json: async () => [{ status: 400 }] })), /every span/);
|
||||
});
|
||||
|
||||
test("workflow exporter runs trusted code and keeps keys out of the PR jobs", () => {
|
||||
const workflow = readFileSync(new URL("../../workflows/ci-profile.yml", import.meta.url), "utf8");
|
||||
assert.match(workflow, /ref: \$\{\{ github.sha \}\}/);
|
||||
assert.match(workflow, /startsWith\(github.event.workflow_run.display_title/);
|
||||
assert.match(workflow, /environment: ci-profiling/);
|
||||
assert.doesNotMatch(workflow, /head_sha|secrets: inherit|contents: write|pull-requests: write|pnpm install/);
|
||||
const pr = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(pr, /HONEYCOMB.*KEY|secrets\./);
|
||||
assert.match(pr, /if: always\(\) && env.PAPERCLIP_CI_PROFILE == 'true'/);
|
||||
assert.doesNotMatch(pr, /PAPERCLIP_CI_PROFILE:.*labels/);
|
||||
assert.equal(pr.match(/PAPERCLIP_CI_PROFILE: \$\{\{ needs.gate.outputs.profile_ci \}\}/g).length, 9);
|
||||
// upload-artifact excludes files inside dot-directories by default. Keep
|
||||
// this opt-in scoped to timing NDJSON, never the workspace or other secrets.
|
||||
assert.equal(pr.match(/path: \.ci-profile\/\*\.ndjson\n {10}include-hidden-files: true/g).length, 9);
|
||||
const routing = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8");
|
||||
assert.match(routing, /PROFILE_CI: \$\{\{ steps.profile.outputs.profile_ci \}\}/);
|
||||
assert.match(routing, /\/git\/ref\/heads\/master/);
|
||||
assert.match(routing, /authorize\.mjs\?ref=\$TRUSTED_PROFILE_SHA/);
|
||||
});
|
||||
|
||||
test("artifact parsing never extracts paths or executes files and rejects oversized records", () => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "ci-artifact-test-"));
|
||||
const archive = path.join(directory, "artifact.zip");
|
||||
const reader = fileURLToPath(new URL("../../../scripts/ci-profile/read-artifact.py", import.meta.url));
|
||||
try {
|
||||
execFileSync("python3", ["-c", "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w'); z.writestr('../escape.ndjson', '{\"kind\":\"test\"}\\n'); z.close()", archive]);
|
||||
const result = execFileSync("python3", [reader, archive], { encoding: "utf8" });
|
||||
assert.deepEqual(JSON.parse(result), { kind: "test" });
|
||||
assert.equal(existsSync(path.join(directory, "escape.ndjson")), false);
|
||||
execFileSync("python3", ["-c", "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w'); z.writestr('large.ndjson', 'x'*33000); z.close()", archive]);
|
||||
assert.notEqual(spawnSync("python3", [reader, archive]).status, 0);
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -97,6 +97,8 @@ const stackedBase = {
|
||||
|
||||
const routeCases = [
|
||||
["allowlisted PR", {}, awsRunner],
|
||||
["allowlisted profile label", { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: "profile-ci" }, awsRunner],
|
||||
["unrelated label", { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: "documentation" }],
|
||||
["validated stacked base", stackedBase, awsRunner],
|
||||
["lagging PR API base snapshot", { TEST_PR_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, awsRunner],
|
||||
["disabled AWS", { AWS_CI_ENABLED: "false" }],
|
||||
@@ -144,6 +146,16 @@ for (const [name, stack, base, expected] of scopeCases) {
|
||||
}
|
||||
|
||||
const files = (...paths) => JSON.stringify(paths.map((filename) => ({ filename })));
|
||||
|
||||
test("profile-ci overrides both middle-stack and release-notes-only skips", () => {
|
||||
for (const overrides of [
|
||||
{ STACK_JSON: JSON.stringify({ position: 2, size: 3, base: { ref: "master" } }), PR_BASE_REF: "stack-1" },
|
||||
{ STACK_JSON: "null", PR_BASE_REF: "master", TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") },
|
||||
]) {
|
||||
assert.equal(executeStep("Select PR CI scope", { ...overrides, PROFILE_CI: "false" }).full_ci, "false");
|
||||
assert.equal(executeStep("Select PR CI scope", { ...overrides, PROFILE_CI: "true" }).full_ci, "true");
|
||||
}
|
||||
});
|
||||
const manyReleaseNotes = JSON.stringify(Array.from({ length: 300 }, (_, index) => ({
|
||||
filename: `releases/beta/v2026.1012.${index}-beta.0.md`,
|
||||
})));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: CI profile
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [PR]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
export:
|
||||
if: >-
|
||||
github.repository == 'paperclipai/paperclip' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
startsWith(github.event.workflow_run.display_title, 'CI profile · PR #')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# This environment permits only the master branch. Do not use a repository
|
||||
# secret: an in-repository PR must not be able to request this credential.
|
||||
environment: ci-profiling
|
||||
steps:
|
||||
# workflow_run is privileged. Never check out its head SHA or run code
|
||||
# from downloaded artifacts. This checkout is the trusted default branch.
|
||||
- name: Checkout trusted exporter
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: scripts/ci-profile
|
||||
|
||||
- name: Export selected complete CI attempt
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HONEYCOMB_CI_API_KEY: ${{ secrets.HONEYCOMB_CI_API_KEY }}
|
||||
HONEYCOMB_CI_DATASET: paperclip-ci
|
||||
HONEYCOMB_TEAM: paperclip
|
||||
HONEYCOMB_ENVIRONMENT: test
|
||||
run: node scripts/ci-profile/export.mjs
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
value: ${{ jobs.gate.outputs.runner }}
|
||||
full_ci:
|
||||
value: ${{ jobs.gate.outputs.full_ci }}
|
||||
profile_ci:
|
||||
value: ${{ jobs.gate.outputs.profile_ci }}
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -22,6 +24,7 @@ jobs:
|
||||
outputs:
|
||||
runner: ${{ steps.route.outputs.runner }}
|
||||
full_ci: ${{ steps.scope.outputs.full_ci }}
|
||||
profile_ci: ${{ steps.profile.outputs.profile_ci }}
|
||||
|
||||
steps:
|
||||
- name: Validate PR identity and select runner
|
||||
@@ -35,6 +38,7 @@ jobs:
|
||||
EVENT_REPOSITORY: ${{ github.repository }}
|
||||
EVENT_REPOSITORY_ID: ${{ github.repository_id }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_LABEL_NAME: ${{ github.event.label.name }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
|
||||
EVENT_SENDER_ID: ${{ github.event.sender.id }}
|
||||
@@ -76,7 +80,10 @@ jobs:
|
||||
[[ "$EVENT_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected repository ID'
|
||||
[[ "$EVENT_BASE_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected base repository ID'
|
||||
[[ -n "$EVENT_BASE_REF" ]] || fail_closed 'missing base branch'
|
||||
[[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize)$ ]] || fail_closed 'unsupported pull_request action'
|
||||
[[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize|labeled)$ ]] || fail_closed 'unsupported pull_request action'
|
||||
if [[ "$EVENT_ACTION" == 'labeled' && "${EVENT_LABEL_NAME:-}" != 'profile-ci' ]]; then
|
||||
fail_closed 'only the profile-ci label can request a profiling run'
|
||||
fi
|
||||
|
||||
jq -e '
|
||||
type == "array" and
|
||||
@@ -215,6 +222,31 @@ jobs:
|
||||
echo "runner=$aws_runner" >> "$GITHUB_OUTPUT"
|
||||
echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner'
|
||||
|
||||
- name: Authorize CODEOWNERS CI profiling
|
||||
id: profile
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PROFILE_REQUESTED: ${{ contains(github.event.pull_request.labels.*.name, 'profile-ci') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$PROFILE_REQUESTED" != 'true' ]]; then
|
||||
echo 'profile_ci=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Fetch only from canonical master, never the PR ref. Pin both the
|
||||
# authorization program and CODEOWNERS to the same trusted revision.
|
||||
if ! TRUSTED_PROFILE_SHA="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" ||
|
||||
[[ ! "$TRUSTED_PROFILE_SHA" =~ ^[0-9a-f]{40}$ ]] ||
|
||||
! gh api -H 'Accept: application/vnd.github.raw+json' \
|
||||
"/repos/paperclipai/paperclip/contents/scripts/ci-profile/authorize.mjs?ref=$TRUSTED_PROFILE_SHA" \
|
||||
> "$RUNNER_TEMP/ci-profile-authorize.mjs"; then
|
||||
echo 'profile_ci=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
export TRUSTED_PROFILE_SHA
|
||||
node "$RUNNER_TEMP/ci-profile-authorize.mjs"
|
||||
|
||||
- name: Select PR CI scope
|
||||
id: scope
|
||||
shell: bash
|
||||
@@ -223,6 +255,7 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PROFILE_CI: ${{ steps.profile.outputs.profile_ci }}
|
||||
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
@@ -293,6 +326,11 @@ jobs:
|
||||
reason='release notes only'
|
||||
fi
|
||||
|
||||
if [[ "${PROFILE_CI:-false}" == 'true' ]]; then
|
||||
full_ci='true'
|
||||
reason='profile-ci requests the complete PR suite'
|
||||
fi
|
||||
|
||||
echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=PR CI scope::$reason; full_ci=$full_ci"
|
||||
|
||||
@@ -11,6 +11,11 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
# Test reporters write local artifacts only. The workflow_run exporter owns
|
||||
# the Honeycomb credential, so fork PRs cannot execute with it.
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE_DIR: ${{ github.workspace }}/.ci-profile
|
||||
|
||||
concurrency:
|
||||
group: pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
@@ -24,6 +29,10 @@ jobs:
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: policy
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -31,6 +40,20 @@ jobs:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Select compatible Node test command
|
||||
run: |
|
||||
# A rerun can use this trusted workflow with a pre-feature checkout.
|
||||
if [[ -f scripts/ci-profile/node-test.mjs ]]; then
|
||||
echo 'CI_NODE_TEST=scripts/ci-profile/node-test.mjs' >> "$GITHUB_ENV"
|
||||
else
|
||||
echo 'CI_NODE_TEST=--test' >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Block manual lockfile edits
|
||||
if: >-
|
||||
github.head_ref != 'chore/refresh-lockfile' &&
|
||||
@@ -76,28 +99,28 @@ jobs:
|
||||
run: node ./scripts/check-no-git-push.mjs
|
||||
|
||||
- name: Test no-git-push check
|
||||
run: node --test ./scripts/check-no-git-push.test.mjs
|
||||
run: node "$CI_NODE_TEST" ./scripts/check-no-git-push.test.mjs
|
||||
|
||||
- name: Validate feature module boundaries
|
||||
run: pnpm check:module-boundaries
|
||||
|
||||
- name: Test feature module boundary check
|
||||
run: node --test ./scripts/check-module-boundaries.test.mjs
|
||||
run: node "$CI_NODE_TEST" ./scripts/check-module-boundaries.test.mjs
|
||||
|
||||
- name: Test PR quality-gate scripts
|
||||
run: node --test '.github/scripts/tests/*.test.mjs'
|
||||
run: node "$CI_NODE_TEST" '.github/scripts/tests/*.test.mjs'
|
||||
|
||||
- name: Test general-server shard partition
|
||||
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
|
||||
run: node "$CI_NODE_TEST" ./scripts/__tests__/run-vitest-stable-shard.test.mjs
|
||||
|
||||
- name: Test e2e shard partition
|
||||
run: node --test ./scripts/__tests__/e2e-shard.test.mjs
|
||||
run: node "$CI_NODE_TEST" ./scripts/__tests__/e2e-shard.test.mjs
|
||||
|
||||
- name: Test release verify workflow wiring
|
||||
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs
|
||||
run: node "$CI_NODE_TEST" ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs
|
||||
|
||||
- name: Test standalone package build concurrency
|
||||
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
|
||||
run: node "$CI_NODE_TEST" ./scripts/__tests__/build-standalone-concurrency.test.mjs
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
@@ -113,6 +136,17 @@ jobs:
|
||||
- name: Validate dependency resolution
|
||||
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
typecheck_release_registry:
|
||||
name: Typecheck + Release Registry
|
||||
needs: [gate]
|
||||
@@ -120,12 +154,21 @@ jobs:
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: Typecheck + Release Registry
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -283,6 +326,17 @@ jobs:
|
||||
- name: Verify release registry test coverage
|
||||
run: pnpm run test:release-registry
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
general_tests:
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
needs: [gate]
|
||||
@@ -398,12 +452,21 @@ jobs:
|
||||
- group: general-workspaces-c
|
||||
group_label: workspaces-c
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: General tests (${{ matrix.group_label }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -456,6 +519,17 @@ jobs:
|
||||
pnpm test:run:general -- --group '${{ matrix.group }}'
|
||||
fi
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
docker_context_integrity:
|
||||
name: Docker context integrity
|
||||
needs: gate
|
||||
@@ -463,6 +537,10 @@ jobs:
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: Docker context integrity
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -472,6 +550,11 @@ jobs:
|
||||
# Not every runner the gate can select ships the Buildx plugin —
|
||||
# the image-build workflows set it up explicitly, so this lane does
|
||||
# too rather than failing before it checks anything.
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
@@ -485,6 +568,17 @@ jobs:
|
||||
- name: Run generated-file drift checks against the Docker build context
|
||||
run: docker buildx build --file .github/docker-context-checks.Dockerfile .
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
verify:
|
||||
# Preserve the legacy required-check name while the underlying work runs in parallel.
|
||||
name: verify
|
||||
@@ -558,12 +652,21 @@ jobs:
|
||||
command: test:typescript:vitest --shard=2/2
|
||||
release_crate: true
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: Verify Paperclip Runner (${{ matrix.lane_label }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -732,6 +835,17 @@ jobs:
|
||||
# read the shard flag as a test filter.
|
||||
run: pnpm --filter @paperclipai/paperclip-runner ${{ matrix.command }}
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [gate]
|
||||
@@ -739,12 +853,21 @@ jobs:
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: Build
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -948,6 +1071,17 @@ jobs:
|
||||
fi
|
||||
node scripts/verify-grok-npm-install.mjs
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
verify_serialized_server:
|
||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
needs: [gate]
|
||||
@@ -994,12 +1128,21 @@ jobs:
|
||||
shard_count: 9
|
||||
shard_label: 9/9
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -1046,6 +1189,17 @@ jobs:
|
||||
- name: Run serialized server test shard
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
canary_dry_run:
|
||||
name: Canary Dry Run
|
||||
needs: [gate]
|
||||
@@ -1053,12 +1207,21 @@ jobs:
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: Canary Dry Run
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -1234,6 +1397,16 @@ jobs:
|
||||
# build` output, which the Build lane also produces, so it moved there:
|
||||
# see "Verify built-in Grok from a clean public npm install" in `build`.
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
e2e_shards:
|
||||
name: e2e shard (${{ matrix.shard_label }})
|
||||
@@ -1281,12 +1454,21 @@ jobs:
|
||||
shard_count: 8
|
||||
shard_label: 8/8
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
PAPERCLIP_CI_PROFILE_JOB: e2e shard (${{ matrix.shard_label }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CI profile
|
||||
continue-on-error: true
|
||||
if: env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
run: node scripts/ci-profile/initialize.mjs
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -1376,6 +1558,17 @@ jobs:
|
||||
tests/e2e/test-results/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload CI profile timings
|
||||
continue-on-error: true
|
||||
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
|
||||
path: .ci-profile/*.ndjson
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
e2e:
|
||||
# Preserve the legacy required-check name while the specs run sharded
|
||||
# across the matrix above (same pattern as the `verify` aggregate).
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
name: PR
|
||||
run-name: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'profile-ci') && (github.event.action != 'labeled' || github.event.label.name == 'profile-ci') && format('CI profile · PR #{0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -10,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'profile-ci'
|
||||
# Master requires CODEOWNERS review for .github/**, including this workflow.
|
||||
# The AWS runner group permits pr-trusted.yml@refs/heads/master.
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@master
|
||||
Reference in new issue
Block a user