mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip uses a paid full-stack campaign to verify runner behavior across providers and environments. > - The campaign already creates an interactive report, workflow logs, and retained evidence artifacts. > - The merge job summary shows result totals but does not link to those resources. > - Reviewers must search several workflow jobs and artifacts to find the executed cells. > - This pull request adds direct and safe links to the exact campaign, each cell, the workflow logs, and the artifacts. > - The benefit is that a reviewer can inspect a result from the Actions summary with one click. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the `Merge and enforce campaign result` summary in the `Runner Full-Stack E2E` workflow. **Subsystem affected** The runner E2E report generator and its GitHub Actions workflow are affected. **Current behavior** The summary lists each selected cell and its result. It does not link to the published campaign report, the workflow logs, or the evidence artifacts. **Proposed behavior** The summary includes a `View results` section. It links to the exact immutable campaign report, the workflow logs, and the artifacts. Each cell name links to its stable section in the campaign report. **Reason and benefit** The current summary does not show reviewers where to inspect the run. Direct links make the result evidence discoverable without manual URL construction or artifact searches. **Breaking changes** None. This change only adds links and stable HTML anchors to existing report output. **Additional context** Related: #12904. The cited successful campaign is [run 34026735033](https://github.com/paperclipai/paperclip/actions/runs/34026735033). ## What Changed - Add a safe URL builder for public campaign, workflow, and artifact links. - Add a `View results` section to the GitHub Actions campaign summary. - Link each summary table cell to its exact section in the immutable campaign report. - Add stable execution anchors to the generated dashboard. - Reject non-HTTPS, credential-bearing, malformed, and ambiguous link destinations. - Document the new links and their retention or publication timing. ## Verification - `pnpm test:e2e:runner:unit` — 116 tests passed. - `pnpm test:e2e:runner:typecheck` — passed. - `pnpm typecheck` — passed, including migration safety. - `pnpm build` — passed. - `pnpm exec prettier --check ...` for all changed files — passed. - `git diff --check origin/master...HEAD` — passed. - The full local server suite also ran. One unrelated macOS workspace-runtime file passed 157 tests and failed 4 existing path and port assumptions. Two failures compare `/var` with `/private/var`. Two failures cannot reserve a port outside a hard-coded range. This PR does not change that file or its dependencies. ## Risks - The immutable campaign link becomes available after the history publisher completes. The workflow and artifact links remain available while publication runs. - The artifact link requires GitHub access and follows the existing 30-day retention period. - Invalid configured URLs are omitted instead of being rendered into the summary. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex desktop agent with GPT-5. The runtime does not expose the context-window size. The agent used repository inspection, agentic reasoning, code execution, and GitHub CLI tools. ## 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/no-internal-issue-references`, `fix/sandbox-secret-resolution`, `feat/adapter-retry-backoff`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
706 lines
20 KiB
TypeScript
706 lines
20 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import {
|
|
cp,
|
|
copyFile,
|
|
mkdtemp,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
realpath,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import os from "node:os";
|
|
import { writePublicCampaignSummaryImage } from "./public-summary-image.js";
|
|
import { regenerateRunnerDashboard } from "./dashboard-regenerate.js";
|
|
import { renderRunnerHistoryIndex } from "./history-index.js";
|
|
import { validateHistoryDestination } from "./history-destination.js";
|
|
import { PUBLIC_RUNNER_SCREENSHOT_MARKER } from "./screenshot-policy.js";
|
|
import {
|
|
campaignHistoryRecord,
|
|
emptyRunnerHistory,
|
|
mergeRunnerHistory,
|
|
} from "./history.js";
|
|
import type { RunnerE2ECampaign, RunnerE2EHistoryIndex } from "./types.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const MUTABLE_HISTORY_FILES = new Set([
|
|
"history.json",
|
|
"latest.json",
|
|
"latest-green.json",
|
|
]);
|
|
const PUBLISH_ROOT_FILES = new Set([
|
|
"dashboard.html",
|
|
"index.html",
|
|
"junit.xml",
|
|
"normalized-results.json",
|
|
"summary.md",
|
|
]);
|
|
const PUBLIC_EVIDENCE_EXTENSIONS = new Set([".json", ".log", ".md", ".txt"]);
|
|
const MAX_PUBLIC_RASTER_BYTES = 12 * 1024 * 1024;
|
|
const EMPTY_PUBLIC_SCREENSHOTS: ReadonlySet<string> = new Set();
|
|
const PRIVATE_EVIDENCE_DIRECTORIES = new Set([
|
|
"blob-report",
|
|
"html-report",
|
|
"playwright-output",
|
|
]);
|
|
|
|
function publicEvidencePath(
|
|
relative: string,
|
|
publicScreenshots: ReadonlySet<string> = EMPTY_PUBLIC_SCREENSHOTS,
|
|
) {
|
|
const match = relative.match(
|
|
/^evidence\/[A-Za-z0-9._-]+\/attempt-[1-9][0-9]*\/(.+)$/,
|
|
);
|
|
if (!match) return false;
|
|
const evidencePath = match[1]!;
|
|
const segments = evidencePath.split("/");
|
|
if (
|
|
segments.some(
|
|
(segment) =>
|
|
!segment ||
|
|
segment === "." ||
|
|
segment === ".." ||
|
|
PRIVATE_EVIDENCE_DIRECTORIES.has(segment),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
PUBLIC_EVIDENCE_EXTENSIONS.has(
|
|
path.posix.extname(evidencePath).toLowerCase(),
|
|
) || publicScreenshots.has(relative)
|
|
);
|
|
}
|
|
|
|
export function publicScreenshotPaths(campaign: RunnerE2ECampaign) {
|
|
const screenshots = new Set<string>();
|
|
for (const result of campaign.results) {
|
|
const files =
|
|
result.screenshots
|
|
?.filter(
|
|
(screenshot) =>
|
|
screenshot.publication === PUBLIC_RUNNER_SCREENSHOT_MARKER,
|
|
)
|
|
.map((screenshot) => screenshot.file) ?? [];
|
|
if (files.length === 0) continue;
|
|
if (
|
|
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,299}$/.test(result.executionId) ||
|
|
!Number.isSafeInteger(result.attempt) ||
|
|
result.attempt < 1
|
|
) {
|
|
throw new Error(
|
|
`Cannot publish screenshots for unsafe execution identity ${result.executionId}`,
|
|
);
|
|
}
|
|
for (const file of files) {
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.png$/i.test(file)) {
|
|
throw new Error(
|
|
`Cannot publish unsafe screenshot path for ${result.executionId}: ${file}`,
|
|
);
|
|
}
|
|
screenshots.add(
|
|
`evidence/${result.executionId}/attempt-${result.attempt}/${file}`,
|
|
);
|
|
}
|
|
}
|
|
return screenshots;
|
|
}
|
|
|
|
export function isHistoricalBundlePathAllowed(
|
|
relative: string,
|
|
allowPublicSummary = false,
|
|
publicScreenshots: ReadonlySet<string> = EMPTY_PUBLIC_SCREENSHOTS,
|
|
) {
|
|
if (
|
|
relative.includes("\\") ||
|
|
relative.startsWith("/") ||
|
|
relative.includes("..")
|
|
) {
|
|
return false;
|
|
}
|
|
if (PUBLISH_ROOT_FILES.has(relative)) return true;
|
|
if (allowPublicSummary && relative === "public-images/campaign-summary.png") {
|
|
return true;
|
|
}
|
|
if (
|
|
relative === "assets/favicon-32x32.png" ||
|
|
relative === "assets/InterVariable.woff2"
|
|
) {
|
|
return true;
|
|
}
|
|
return publicEvidencePath(relative, publicScreenshots);
|
|
}
|
|
|
|
function hasPublicPngMagic(content: Buffer) {
|
|
return (
|
|
content.length >= 24 &&
|
|
content
|
|
.subarray(0, 8)
|
|
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) &&
|
|
content.subarray(12, 16).equals(Buffer.from("IHDR", "ascii"))
|
|
);
|
|
}
|
|
|
|
async function validatePublicRaster(absolute: string, relative: string) {
|
|
const metadata = await stat(absolute);
|
|
if (metadata.size === 0 || metadata.size > MAX_PUBLIC_RASTER_BYTES) {
|
|
throw new Error(
|
|
`Public screenshot ${relative} exceeds the per-file size boundary`,
|
|
);
|
|
}
|
|
const content = await readFile(absolute);
|
|
if (!hasPublicPngMagic(content)) {
|
|
throw new Error(
|
|
`Public screenshot ${relative} does not match its raster file type`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function pruneEvidenceDirectory(
|
|
root: string,
|
|
current: string,
|
|
publicScreenshots: ReadonlySet<string>,
|
|
) {
|
|
const entries = await readdir(current, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await pruneEvidenceDirectory(root, absolute, publicScreenshots);
|
|
if ((await readdir(absolute)).length === 0) {
|
|
await rm(absolute, { recursive: true });
|
|
}
|
|
continue;
|
|
}
|
|
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
if (!entry.isFile() || !publicEvidencePath(relative, publicScreenshots)) {
|
|
await rm(absolute, { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function prunePrivateHistoryEvidence(
|
|
root: string,
|
|
publicScreenshots: ReadonlySet<string> = EMPTY_PUBLIC_SCREENSHOTS,
|
|
) {
|
|
const evidenceRoot = path.join(root, "evidence");
|
|
const metadata = await lstat(evidenceRoot).catch(() => null);
|
|
if (!metadata) return;
|
|
if (!metadata.isDirectory()) {
|
|
throw new Error("Historical evidence root must be a directory");
|
|
}
|
|
await pruneEvidenceDirectory(root, evidenceRoot, publicScreenshots);
|
|
}
|
|
|
|
export async function stageTrustedHistoryAssets(
|
|
root: string,
|
|
trustedRoot = repositoryRoot,
|
|
) {
|
|
const resolveTrustedAsset = async (segments: string[]) => {
|
|
const trustedRootReal = await realpath(trustedRoot);
|
|
let source = trustedRoot;
|
|
for (const [index, segment] of segments.entries()) {
|
|
source = path.join(source, segment);
|
|
const metadata = await lstat(source);
|
|
if (metadata.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Refusing symbolic link in trusted publisher asset path ${segments.join("/")}`,
|
|
);
|
|
}
|
|
const final = index === segments.length - 1;
|
|
if (
|
|
(final && !metadata.isFile()) ||
|
|
(!final && !metadata.isDirectory())
|
|
) {
|
|
throw new Error(
|
|
`Trusted publisher asset path has an invalid file type: ${segments.join("/")}`,
|
|
);
|
|
}
|
|
}
|
|
const sourceReal = await realpath(source);
|
|
const relative = path.relative(trustedRootReal, sourceReal);
|
|
if (
|
|
!relative ||
|
|
relative === ".." ||
|
|
relative.startsWith(`..${path.sep}`)
|
|
) {
|
|
throw new Error(
|
|
`Trusted publisher asset escapes its checkout: ${segments.join("/")}`,
|
|
);
|
|
}
|
|
return sourceReal;
|
|
};
|
|
const [faviconSource, fontSource] = await Promise.all([
|
|
resolveTrustedAsset(["ui", "public", "favicon-32x32.png"]),
|
|
resolveTrustedAsset(["ui", "public", "fonts", "InterVariable.woff2"]),
|
|
]);
|
|
const assets = path.join(root, "assets");
|
|
await rm(assets, { recursive: true, force: true });
|
|
await mkdir(assets, { recursive: true });
|
|
await Promise.all([
|
|
copyFile(faviconSource, path.join(assets, "favicon-32x32.png")),
|
|
copyFile(fontSource, path.join(assets, "InterVariable.woff2")),
|
|
]);
|
|
}
|
|
|
|
interface BundleManifest {
|
|
schema: "paperclip.runner-e2e.bundle/v1";
|
|
campaignId: string;
|
|
bundleDigest: string;
|
|
files: Array<{ path: string; sha256: string; bytes: number }>;
|
|
}
|
|
|
|
function json(value: unknown) {
|
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
}
|
|
|
|
export { validateHistoryDestination };
|
|
|
|
async function relativeFiles(
|
|
root: string,
|
|
current = root,
|
|
allowPublicSummary = false,
|
|
publicScreenshots: ReadonlySet<string> = EMPTY_PUBLIC_SCREENSHOTS,
|
|
): Promise<string[]> {
|
|
const entries = await readdir(current, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
for (const entry of entries) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isSymbolicLink()) {
|
|
throw new Error(`Refusing to publish symbolic link ${entry.name}`);
|
|
}
|
|
if (entry.isDirectory()) {
|
|
files.push(
|
|
...(await relativeFiles(
|
|
root,
|
|
absolute,
|
|
allowPublicSummary,
|
|
publicScreenshots,
|
|
)),
|
|
);
|
|
} else if (entry.isFile()) {
|
|
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
if (MUTABLE_HISTORY_FILES.has(relative)) continue;
|
|
if (
|
|
!isHistoricalBundlePathAllowed(
|
|
relative,
|
|
allowPublicSummary,
|
|
publicScreenshots,
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`Refusing non-allowlisted historical bundle path ${relative}`,
|
|
);
|
|
}
|
|
files.push(relative);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export async function createBundleManifest(
|
|
root: string,
|
|
campaignId: string,
|
|
allowPublicSummary = false,
|
|
publicScreenshots: ReadonlySet<string> = EMPTY_PUBLIC_SCREENSHOTS,
|
|
): Promise<BundleManifest> {
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(campaignId)) {
|
|
throw new Error("Campaign ID is unsafe for immutable object storage");
|
|
}
|
|
const files = await Promise.all(
|
|
(await relativeFiles(root, root, allowPublicSummary, publicScreenshots))
|
|
.sort()
|
|
.map(async (relative) => {
|
|
const absolute = path.join(root, ...relative.split("/"));
|
|
if (
|
|
relative === "public-images/campaign-summary.png" ||
|
|
publicScreenshots.has(relative)
|
|
) {
|
|
await validatePublicRaster(absolute, relative);
|
|
}
|
|
const [content, metadata] = await Promise.all([
|
|
readFile(absolute),
|
|
stat(absolute),
|
|
]);
|
|
return {
|
|
path: relative,
|
|
sha256: createHash("sha256").update(content).digest("hex"),
|
|
bytes: metadata.size,
|
|
};
|
|
}),
|
|
);
|
|
const bundleDigest = createHash("sha256")
|
|
.update(JSON.stringify(files))
|
|
.digest("hex");
|
|
return {
|
|
schema: "paperclip.runner-e2e.bundle/v1",
|
|
campaignId,
|
|
bundleDigest,
|
|
files,
|
|
};
|
|
}
|
|
|
|
export function buildHistoryPointers(history: RunnerE2EHistoryIndex) {
|
|
const byCampaign = new Map(
|
|
history.campaigns.map((campaign) => [campaign.campaignId, campaign]),
|
|
);
|
|
const pointer = (campaignId: string | null | undefined) => {
|
|
const campaign = campaignId ? byCampaign.get(campaignId) : undefined;
|
|
return campaign
|
|
? {
|
|
campaignId: campaign.campaignId,
|
|
generatedAt: campaign.generatedAt,
|
|
publicUrl: campaign.publicUrl,
|
|
sha: campaign.source.sha,
|
|
}
|
|
: null;
|
|
};
|
|
return {
|
|
latest: {
|
|
schema: "paperclip.runner-e2e.pointer/v1",
|
|
updatedAt: history.updatedAt,
|
|
overall: pointer(history.latestCampaignId),
|
|
suites: Object.fromEntries(
|
|
Object.entries(history.latestBySuite).map(([suiteId, campaignId]) => [
|
|
suiteId,
|
|
pointer(campaignId),
|
|
]),
|
|
),
|
|
},
|
|
latestGreen: {
|
|
schema: "paperclip.runner-e2e.pointer/v1",
|
|
updatedAt: history.updatedAt,
|
|
overall: pointer(history.latestGreenCampaignId),
|
|
suites: Object.fromEntries(
|
|
Object.entries(history.latestGreenBySuite).map(
|
|
([suiteId, campaignId]) => [suiteId, pointer(campaignId)],
|
|
),
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
function awsObject(bucket: string, key: string) {
|
|
return `s3://${bucket}/${key}`;
|
|
}
|
|
|
|
async function objectExists(bucket: string, key: string) {
|
|
try {
|
|
await execFileAsync("aws", [
|
|
"s3api",
|
|
"head-object",
|
|
"--bucket",
|
|
bucket,
|
|
"--key",
|
|
key,
|
|
]);
|
|
return true;
|
|
} catch (error) {
|
|
const detail = String(
|
|
(error as { stderr?: string }).stderr ??
|
|
(error instanceof Error ? error.message : error),
|
|
);
|
|
if (/\b(?:404|Not Found|NoSuchKey)\b/i.test(detail)) return false;
|
|
throw new Error(
|
|
`Unable to inspect historical object: ${detail.slice(0, 400)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function downloadJson<T>(
|
|
bucket: string,
|
|
key: string,
|
|
destination: string,
|
|
) {
|
|
if (!(await objectExists(bucket, key))) return null;
|
|
await execFileAsync("aws", [
|
|
"s3",
|
|
"cp",
|
|
awsObject(bucket, key),
|
|
destination,
|
|
"--only-show-errors",
|
|
]);
|
|
return JSON.parse(await readFile(destination, "utf8")) as T;
|
|
}
|
|
|
|
async function uploadJson(
|
|
bucket: string,
|
|
key: string,
|
|
file: string,
|
|
cacheControl: string,
|
|
) {
|
|
await uploadFile(bucket, key, file, "application/json", cacheControl);
|
|
}
|
|
|
|
async function uploadFile(
|
|
bucket: string,
|
|
key: string,
|
|
file: string,
|
|
contentType: string,
|
|
cacheControl: string,
|
|
) {
|
|
await execFileAsync("aws", [
|
|
"s3",
|
|
"cp",
|
|
file,
|
|
awsObject(bucket, key),
|
|
"--only-show-errors",
|
|
"--content-type",
|
|
contentType,
|
|
"--cache-control",
|
|
cacheControl,
|
|
]);
|
|
}
|
|
|
|
async function uploadImmutableBundle(
|
|
bucket: string,
|
|
key: string,
|
|
directory: string,
|
|
) {
|
|
const common = [
|
|
"--recursive",
|
|
"--only-show-errors",
|
|
"--cache-control",
|
|
"public,max-age=31536000,immutable",
|
|
];
|
|
await execFileAsync("aws", [
|
|
"s3",
|
|
"cp",
|
|
directory,
|
|
awsObject(bucket, key),
|
|
...common,
|
|
"--exclude",
|
|
"history.json",
|
|
"--exclude",
|
|
"latest.json",
|
|
"--exclude",
|
|
"latest-green.json",
|
|
"--exclude",
|
|
"*.png",
|
|
]);
|
|
await execFileAsync("aws", [
|
|
"s3",
|
|
"cp",
|
|
directory,
|
|
awsObject(bucket, key),
|
|
...common,
|
|
"--exclude",
|
|
"*",
|
|
"--include",
|
|
"*.png",
|
|
"--content-type",
|
|
"image/png",
|
|
]);
|
|
}
|
|
|
|
async function main() {
|
|
const reportRoot = path.resolve(
|
|
process.env.PAPERCLIP_RUNNER_E2E_REPORT_DIR ??
|
|
"runner-e2e-merged-report/normalized",
|
|
);
|
|
const bucket = process.env.RUNNER_E2E_HISTORY_S3_BUCKET ?? "";
|
|
const destination = validateHistoryDestination({
|
|
bucket,
|
|
prefix: process.env.RUNNER_E2E_HISTORY_PREFIX ?? "runner-e2e",
|
|
publicBaseUrl: process.env.RUNNER_E2E_HISTORY_PUBLIC_BASE_URL ?? "",
|
|
});
|
|
const campaign = JSON.parse(
|
|
await readFile(path.join(reportRoot, "normalized-results.json"), "utf8"),
|
|
) as RunnerE2ECampaign;
|
|
if (campaign.schema !== "paperclip.runner-e2e.campaign/v2") {
|
|
throw new Error("Historical publishing requires a v2 normalized campaign");
|
|
}
|
|
|
|
const temporary = await mkdtemp(
|
|
path.join(os.tmpdir(), "runner-e2e-history-"),
|
|
);
|
|
const historyKey = `${destination.prefix}/history.json`;
|
|
const current =
|
|
(await downloadJson<RunnerE2EHistoryIndex>(
|
|
bucket,
|
|
historyKey,
|
|
path.join(temporary, "current-history.json"),
|
|
)) ?? emptyRunnerHistory();
|
|
const history = mergeRunnerHistory(
|
|
current,
|
|
campaignHistoryRecord(
|
|
campaign,
|
|
`${destination.publicBaseUrl}/${destination.prefix}`,
|
|
),
|
|
);
|
|
const publicScreenshots = publicScreenshotPaths(campaign);
|
|
|
|
// Campaign bundles are immutable and must not capture a mutable history
|
|
// file left in a reused local directory. The root landing page below is the
|
|
// only dashboard that embeds navigation across campaigns. S3 gets its own
|
|
// staged copy. Only screenshots declared by normalized results (plus the
|
|
// fixed failure screenshot for failed executions) cross the public boundary;
|
|
// all other target-produced files remain subject to the structured allowlist.
|
|
const s3ReportRoot = path.join(temporary, "s3-campaign");
|
|
await cp(reportRoot, s3ReportRoot, { recursive: true, errorOnExist: true });
|
|
await prunePrivateHistoryEvidence(s3ReportRoot, publicScreenshots);
|
|
await stageTrustedHistoryAssets(s3ReportRoot);
|
|
await rm(path.join(s3ReportRoot, "public-images"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
const summaryImage = path.join(
|
|
s3ReportRoot,
|
|
"public-images",
|
|
"campaign-summary.png",
|
|
);
|
|
await writePublicCampaignSummaryImage(campaign, summaryImage);
|
|
await regenerateRunnerDashboard({
|
|
bundle: s3ReportRoot,
|
|
historyFile: null,
|
|
publicSummaryImageHref: "public-images/campaign-summary.png",
|
|
});
|
|
const manifest = await createBundleManifest(
|
|
s3ReportRoot,
|
|
campaign.campaignId,
|
|
true,
|
|
publicScreenshots,
|
|
);
|
|
const campaignPrefix = `${destination.prefix}/campaigns/${campaign.campaignId}`;
|
|
const manifestKey = `${campaignPrefix}/bundle-manifest.json`;
|
|
const existingManifest = await downloadJson<BundleManifest>(
|
|
bucket,
|
|
manifestKey,
|
|
path.join(temporary, "existing-manifest.json"),
|
|
);
|
|
if (
|
|
existingManifest &&
|
|
existingManifest.bundleDigest !== manifest.bundleDigest
|
|
) {
|
|
throw new Error(
|
|
`Immutable campaign ${campaign.campaignId} already exists with a different digest`,
|
|
);
|
|
}
|
|
if (!existingManifest) {
|
|
await uploadImmutableBundle(bucket, campaignPrefix, s3ReportRoot);
|
|
const manifestFile = path.join(temporary, "bundle-manifest.json");
|
|
await writeFile(manifestFile, json(manifest), "utf8");
|
|
await uploadJson(
|
|
bucket,
|
|
manifestKey,
|
|
manifestFile,
|
|
"public,max-age=31536000,immutable",
|
|
);
|
|
}
|
|
|
|
// Pages uses the same declared-screenshot boundary in a sibling stage. The
|
|
// downloaded access-controlled report stays intact for this whole job.
|
|
const pagesRoot = path.join(path.dirname(reportRoot), "pages");
|
|
await rm(pagesRoot, { recursive: true, force: true });
|
|
await cp(reportRoot, pagesRoot, { recursive: true, errorOnExist: true });
|
|
await prunePrivateHistoryEvidence(pagesRoot, publicScreenshots);
|
|
await stageTrustedHistoryAssets(pagesRoot);
|
|
await rm(path.join(pagesRoot, "public-images"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
const pointers = buildHistoryPointers(history);
|
|
const historyFile = path.join(pagesRoot, "history.json");
|
|
const latestFile = path.join(pagesRoot, "latest.json");
|
|
const latestGreenFile = path.join(pagesRoot, "latest-green.json");
|
|
await Promise.all([
|
|
writeFile(historyFile, json(history), "utf8"),
|
|
writeFile(latestFile, json(pointers.latest), "utf8"),
|
|
writeFile(latestGreenFile, json(pointers.latestGreen), "utf8"),
|
|
]);
|
|
await regenerateRunnerDashboard({ bundle: pagesRoot, historyFile });
|
|
await createBundleManifest(
|
|
pagesRoot,
|
|
campaign.campaignId,
|
|
false,
|
|
publicScreenshots,
|
|
);
|
|
const landingDirectory = path.join(temporary, "landing");
|
|
await regenerateRunnerDashboard({
|
|
bundle: s3ReportRoot,
|
|
historyFile,
|
|
outputDirectory: landingDirectory,
|
|
evidenceHrefPrefix: `campaigns/${campaign.campaignId}`,
|
|
publicSummaryImageHref: `campaigns/${campaign.campaignId}/public-images/campaign-summary.png`,
|
|
});
|
|
await writeFile(
|
|
path.join(landingDirectory, "index.html"),
|
|
renderRunnerHistoryIndex(history, {
|
|
latestSummaryImageHref:
|
|
history.latestCampaignId === campaign.campaignId
|
|
? `campaigns/${campaign.campaignId}/public-images/campaign-summary.png`
|
|
: undefined,
|
|
}),
|
|
"utf8",
|
|
);
|
|
await Promise.all([
|
|
uploadJson(bucket, historyKey, historyFile, "no-cache"),
|
|
uploadJson(
|
|
bucket,
|
|
`${destination.prefix}/latest.json`,
|
|
latestFile,
|
|
"no-cache",
|
|
),
|
|
uploadJson(
|
|
bucket,
|
|
`${destination.prefix}/latest-green.json`,
|
|
latestGreenFile,
|
|
"no-cache",
|
|
),
|
|
uploadFile(
|
|
bucket,
|
|
`${destination.prefix}/index.html`,
|
|
path.join(landingDirectory, "index.html"),
|
|
"text/html; charset=utf-8",
|
|
"no-cache",
|
|
),
|
|
uploadFile(
|
|
bucket,
|
|
`${destination.prefix}/dashboard.html`,
|
|
path.join(landingDirectory, "dashboard.html"),
|
|
"text/html; charset=utf-8",
|
|
"no-cache",
|
|
),
|
|
uploadFile(
|
|
bucket,
|
|
`${destination.prefix}/normalized-results.json`,
|
|
path.join(landingDirectory, "normalized-results.json"),
|
|
"application/json",
|
|
"no-cache",
|
|
),
|
|
uploadFile(
|
|
bucket,
|
|
`${destination.prefix}/assets/favicon-32x32.png`,
|
|
path.join(s3ReportRoot, "assets", "favicon-32x32.png"),
|
|
"image/png",
|
|
"public,max-age=86400",
|
|
),
|
|
uploadFile(
|
|
bucket,
|
|
`${destination.prefix}/assets/InterVariable.woff2`,
|
|
path.join(s3ReportRoot, "assets", "InterVariable.woff2"),
|
|
"font/woff2",
|
|
"public,max-age=86400",
|
|
),
|
|
]);
|
|
console.log(
|
|
`Published immutable campaign ${campaign.campaignId} (${manifest.bundleDigest}) and ${history.campaigns.length} history record(s)`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
|
|
) {
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|