ci(runner): reuse Daytona images by content

This commit is contained in:
Dotta committed 2026-08-27 12:13:20 -05:00
1 parent 42e8ac9d6f
commit c6c0a431bf
8 files changed
+309 -20

No files matched your search

+24 -1
View File
@@ -46,6 +46,7 @@ jobs:
matrix: ${{ steps.catalog.outputs.matrix }}
needs_daytona: ${{ steps.catalog.outputs.needs_daytona }}
execution_ids: ${{ steps.catalog.outputs.execution_ids }}
daytona_image_content_id: ${{ steps.daytona_image_content.outputs.content_id }}
steps:
- uses: actions/checkout@v7
@@ -60,6 +61,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Compute Daytona image content ID
id: daytona_image_content
run: echo "content_id=$(pnpm --silent test:e2e:runner:image-id)" >> "$GITHUB_OUTPUT"
- name: Validate selectors and emit matrix
id: catalog
env:
@@ -128,6 +133,8 @@ jobs:
id-token: write
outputs:
image: ${{ steps.image.outputs.image }}
source_revision: ${{ steps.image.outputs.source_revision }}
content_id: ${{ steps.image.outputs.content_id }}
steps:
- uses: actions/checkout@v7
@@ -156,19 +163,24 @@ jobs:
id: image
env:
NEEDS_DAYTONA: ${{ needs.catalog.outputs.needs_daytona }}
IMAGE_TAG: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-git-${{ github.sha }}
IMAGE_CONTENT_ID: ${{ needs.catalog.outputs.daytona_image_content_id }}
IMAGE_TAG: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-${{ needs.catalog.outputs.daytona_image_content_id }}
run: |
set -euo pipefail
if [ "$NEEDS_DAYTONA" != true ]; then
echo "image=" >> "$GITHUB_OUTPUT"
echo "source_revision=" >> "$GITHUB_OUTPUT"
echo "content_id=" >> "$GITHUB_OUTPUT"
exit 0
fi
[[ "$IMAGE_CONTENT_ID" =~ ^[0-9a-f]{64}$ ]]
identity="^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/runner-full-stack-e2e.yml@"
if docker buildx imagetools inspect "$IMAGE_TAG" >/dev/null 2>&1; then
digest="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
else
docker buildx build \
--platform linux/amd64 \
--build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${IMAGE_CONTENT_ID}" \
--build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${GITHUB_SHA}" \
--file docker/daytona-runner/Dockerfile \
--tag "$IMAGE_TAG" \
@@ -198,6 +210,10 @@ jobs:
# configuration here without creating a redundant container.
image_config="$(docker image inspect "$immutable" \
--format '{{json .}}')"
published_content_id="$(jq -r '.Config.Labels["io.paperclip.runner.content-id"] // empty' <<< "$image_config")"
source_revision="$(jq -r '.Config.Labels["org.opencontainers.image.revision"] // empty' <<< "$image_config")"
test "$published_content_id" = "$IMAGE_CONTENT_ID"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]]
jq -e \
'.Architecture == "amd64" and
.Os == "linux" and
@@ -205,6 +221,8 @@ jobs:
(.Config.Env | any(startswith("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=")))' \
<<< "$image_config" >/dev/null
echo "image=$immutable" >> "$GITHUB_OUTPUT"
echo "source_revision=$source_revision" >> "$GITHUB_OUTPUT"
echo "content_id=$published_content_id" >> "$GITHUB_OUTPUT"
test:
name: ${{ matrix.executionId }}
@@ -236,6 +254,11 @@ jobs:
- name: Build native remote provider pack
if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-'))
env:
# Reused images retain the source revision that was embedded in their
# provider pack. Matching it here lets the server reuse that exact
# preinstalled pack instead of uploading a duplicate to the lease.
PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}
run: pnpm --filter @paperclipai/paperclip-runner build:provider-pack
- name: Install pinned legacy Claude CLI
+10 -5
View File
@@ -128,11 +128,16 @@ schedulable profile/environment/case jobs for a full run. The paid matrix uses
`fail-fast:false`, `max-parallel:16`, and 25-minute local or 40-minute Daytona
job limits that cover the scenario deadline plus one fresh-harness retry.
When Daytona is selected, one image job reuses a verified digest for the tested
SHA or builds `linux/amd64` from `docker/daytona-runner/Dockerfile`, pushes
`e2e-git-<sha>`, signs with Cosign/OIDC, verifies anonymous pull, checks runner
contract/transport metadata, and exposes the immutable digest to every Daytona
cell.
When Daytona is selected, one image job computes an audited content ID from the
`linux/amd64` platform, Dockerfile, root package/lock/build configuration,
dependency patches, eval-kernel package, and runner package. It reuses the
verified `e2e-content-<sha256>` image when that dependency closure is unchanged,
or builds and publishes it otherwise. The tested Git SHA remains separate image
provenance. Reuse reads that original revision back into the controller-side
provider-pack build so its manifest remains byte-identical to the preinstalled
pack. The job signs with Cosign/OIDC, verifies anonymous pull, checks the content
label and runner contract/transport metadata, and exposes the immutable digest
to every Daytona cell.
Every execution uploads a 30-day sanitized bundle. The final job always downloads
evidence, merges Playwright blob reports into HTML/JUnit, stages the sanitized
+7
View File
@@ -32,6 +32,13 @@ RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \
# runner-specific addition is /usr/local/bin/paperclip-runnerd below.
FROM daytonaio/sandbox:0.8.0
ARG PAPERCLIP_RUNNER_CONTENT_ID
ARG PAPERCLIP_RUNNER_SOURCE_REVISION
RUN test -n "${PAPERCLIP_RUNNER_CONTENT_ID}" \
&& test -n "${PAPERCLIP_RUNNER_SOURCE_REVISION}"
LABEL io.paperclip.runner.content-id="${PAPERCLIP_RUNNER_CONTENT_ID}" \
org.opencontainers.image.revision="${PAPERCLIP_RUNNER_SOURCE_REVISION}"
USER root
ENV PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=/opt/paperclip-runner/provider-pack
+10 -2
View File
@@ -18,17 +18,19 @@ The fleet image is currently amd64-only because the pinned Cursor and GitHub CLI
checksums cover amd64.
```bash
content_id="$(pnpm --silent test:e2e:runner:image-id)"
docker buildx build \
--platform linux/amd64 \
--build-arg PAPERCLIP_RUNNER_CONTENT_ID="${content_id}" \
--build-arg PAPERCLIP_RUNNER_SOURCE_REVISION="$(git rev-parse HEAD)" \
--tag paperclip-daytona-runner:dev \
--tag "paperclip-daytona-runner:e2e-content-${content_id}" \
--load \
--file docker/daytona-runner/Dockerfile \
.
docker run --rm --platform linux/amd64 \
--entrypoint paperclip-runnerd \
paperclip-daytona-runner:dev \
"paperclip-daytona-runner:e2e-content-${content_id}" \
--build-metadata
```
@@ -36,6 +38,12 @@ The metadata must advertise `dial_ws_loopback`, `dial_wss`, and `listen_ws`.
The explicit entrypoint is needed only for this local probe because Daytona's
base image uses its own long-running sandbox entrypoint.
`test:e2e:runner:image-id` hashes the audited Docker build dependency closure
and target platform. Git commits that do not change those inputs reuse the same
content tag. `PAPERCLIP_RUNNER_SOURCE_REVISION` remains the full Git SHA that
built the first published copy and is retained as provenance rather than cache
identity.
## Use in Paperclip
Publish the image to a registry Daytona can pull, or use the environment
+1
View File
@@ -62,6 +62,7 @@
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
"test:e2e": "pnpm run preflight:workspace-links && npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:runner": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/launch.ts",
"test:e2e:runner:image-id": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/daytona-image-content.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts",
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
"test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts",
+23 -9
View File
@@ -104,23 +104,37 @@ Use the immutable digest printed by the `Publish verified Daytona image` job,
or publish the current source locally:
```bash
image="ghcr.io/paperclipai/paperclip-daytona-runner:e2e-git-$(git rev-parse HEAD)"
docker buildx build \
--platform linux/amd64 \
--build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=$(git rev-parse HEAD)" \
--file docker/daytona-runner/Dockerfile \
--tag "$image" \
--push \
.
content_id="$(pnpm --silent test:e2e:runner:image-id)"
source_revision="$(git rev-parse HEAD)"
image="ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-${content_id}"
if ! docker buildx imagetools inspect "$image" >/dev/null 2>&1; then
docker buildx build \
--platform linux/amd64 \
--build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${content_id}" \
--build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${source_revision}" \
--file docker/daytona-runner/Dockerfile \
--tag "$image" \
--push \
.
fi
docker buildx imagetools inspect "$image"
```
The content ID hashes the audited image inputs, including the Dockerfile,
platform, root package/lock/build configuration, dependency patches,
`paperclip-eval-kernel`, and `paperclip-runner`. Changes elsewhere in the
repository keep the same tag and reuse the already signed image. The Git SHA is
stored separately as image provenance. CI reads that provenance back from a
reused image when it builds the controller-side provider pack, preserving the
exact manifest match required to avoid restaging the pack into Daytona.
Resolve the manifest digest and set `PAPERCLIP_E2E_DAYTONA_IMAGE` to
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:...`. The repository
workflow signs that digest with Cosign/OIDC and verifies that it is publicly
pullable, includes the provider pack, and advertises `dial_ws_loopback`,
`dial_wss`, and `listen_ws`. The GHCR package must be configured as public;
the image job deliberately fails its anonymous-pull check otherwise.
the image job deliberately fails its anonymous-pull check otherwise. Existing
content tags are never rebuilt or overwritten by the workflow.
## Evidence and cleanup
+144
View File
@@ -0,0 +1,144 @@
import { createHash } from "node:crypto";
import { lstat, readdir, readFile, readlink } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
export const DAYTONA_IMAGE_CONTENT_SCHEMA =
"paperclip-daytona-runner-image-content/v1";
export const DAYTONA_IMAGE_PLATFORM = "linux/amd64";
// This is the audited dependency closure of docker/daytona-runner/Dockerfile.
// Keep it conservative: a false positive only rebuilds the image, while a
// missing input could incorrectly reuse an incompatible paid-test image.
export const DAYTONA_IMAGE_INPUT_PATHS = [
".dockerignore",
".npmrc",
"docker/daytona-runner/Dockerfile",
"package.json",
"patches",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/link-plugin-dev-sdk.mjs",
"tsconfig.base.json",
"packages/paperclip-eval-kernel",
"packages/paperclip-runner",
] as const;
const ignoredDirectoryPaths = new Set([
"packages/paperclip-runner/dist",
"packages/paperclip-runner/runner/target",
]);
export interface DaytonaImageContentOptions {
repositoryRoot?: string;
inputPaths?: readonly string[];
platform?: string;
}
function compareNames(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function normalizedRelativePath(value: string): string {
return value.split(path.sep).join("/");
}
function shouldIgnore(relativePath: string): boolean {
if (ignoredDirectoryPaths.has(relativePath)) return true;
return relativePath.split("/").includes("node_modules");
}
function updateRecord(
hash: ReturnType<typeof createHash>,
kind: string,
relativePath: string,
payload = "",
): void {
hash.update(kind);
hash.update("\0");
hash.update(relativePath);
hash.update("\0");
hash.update(payload);
hash.update("\0");
}
async function hashEntry(
hash: ReturnType<typeof createHash>,
root: string,
relativePath: string,
): Promise<void> {
const normalizedPath = normalizedRelativePath(relativePath);
if (shouldIgnore(normalizedPath)) return;
const absolutePath = path.resolve(root, relativePath);
const relativeFromRoot = path.relative(root, absolutePath);
if (
relativeFromRoot.startsWith(`..${path.sep}`) ||
relativeFromRoot === ".." ||
path.isAbsolute(relativeFromRoot)
) {
throw new Error(
`Daytona image input escapes repository root: ${relativePath}`,
);
}
const stats = await lstat(absolutePath);
if (stats.isDirectory()) {
updateRecord(hash, "directory", normalizedPath);
const entries = await readdir(absolutePath, { withFileTypes: true });
entries.sort((left, right) => compareNames(left.name, right.name));
for (const entry of entries) {
await hashEntry(hash, root, path.join(relativePath, entry.name));
}
return;
}
if (stats.isSymbolicLink()) {
updateRecord(hash, "symlink", normalizedPath, await readlink(absolutePath));
return;
}
if (stats.isFile()) {
const executable =
(stats.mode & 0o111) === 0 ? "non-executable" : "executable";
updateRecord(hash, "file", normalizedPath, executable);
hash.update(await readFile(absolutePath));
hash.update("\0");
return;
}
throw new Error(`Unsupported Daytona image input type: ${relativePath}`);
}
export async function computeDaytonaImageContentId(
options: DaytonaImageContentOptions = {},
): Promise<string> {
const root = path.resolve(options.repositoryRoot ?? repositoryRoot);
const inputPaths = [
...(options.inputPaths ?? DAYTONA_IMAGE_INPUT_PATHS),
].sort(compareNames);
const hash = createHash("sha256");
updateRecord(
hash,
"contract",
DAYTONA_IMAGE_CONTENT_SCHEMA,
options.platform ?? DAYTONA_IMAGE_PLATFORM,
);
for (const inputPath of inputPaths) {
await hashEntry(hash, root, inputPath);
}
return hash.digest("hex");
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
computeDaytonaImageContentId()
.then((contentId) => process.stdout.write(`${contentId}\n`))
.catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
}
export const DAYTONA_IMAGE_CONTENT_SCRIPT = fileURLToPath(import.meta.url);
+90 -3
View File
@@ -1,16 +1,22 @@
import { readFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
computeDaytonaImageContentId,
DAYTONA_IMAGE_INPUT_PATHS,
} from "./daytona-image-content.js";
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
describe("runner E2E Daytona image contract", () => {
it("builds runnerd and the provider pack and verifies every required transport", async () => {
const [dockerfile, workflow] = await Promise.all([
const [dockerfile, dockerignore, workflow] = await Promise.all([
readFile(
path.join(repositoryRoot, "docker/daytona-runner/Dockerfile"),
"utf8",
),
readFile(path.join(repositoryRoot, ".dockerignore"), "utf8"),
readFile(
path.join(
repositoryRoot,
@@ -33,9 +39,23 @@ describe("runner E2E Daytona image contract", () => {
for (const transport of ["dial_ws_loopback", "dial_wss", "listen_ws"]) {
expect(dockerfile).toContain(transport);
}
expect(dockerfile).toContain('metadata="$(paperclip-runnerd --build-metadata)"');
expect(dockerfile).toContain(
'metadata="$(paperclip-runnerd --build-metadata)"',
);
expect(dockerfile).toContain("provider-pack.json");
expect(dockerfile).toContain("io.paperclip.runner.content-id");
expect(dockerfile).toContain("org.opencontainers.image.revision");
expect(dockerignore).toContain("**/node_modules");
expect(dockerignore).toContain("packages/paperclip-runner/dist");
expect(dockerignore).toContain("packages/paperclip-runner/runner/target");
expect(workflow).toContain("--platform linux/amd64");
expect(workflow).toContain(
"e2e-content-${{ needs.catalog.outputs.daytona_image_content_id }}",
);
expect(workflow).toContain(
'--build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${IMAGE_CONTENT_ID}"',
);
expect(workflow).not.toContain("e2e-git-${{ github.sha }}");
expect(workflow).toContain("cosign sign --yes");
expect(workflow).toContain("docker image inspect");
expect(workflow).toContain('.Config.User == "daytona"');
@@ -46,6 +66,73 @@ describe("runner E2E Daytona image contract", () => {
expect(workflow).toContain(
"PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack",
);
expect(workflow).toContain(
"PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}",
);
expect(workflow).toContain("anonymous_config");
});
it("hashes the audited image dependency closure rather than the repository revision", async () => {
for (const requiredPath of [
".dockerignore",
"docker/daytona-runner/Dockerfile",
"pnpm-lock.yaml",
"patches",
"packages/paperclip-eval-kernel",
"packages/paperclip-runner",
]) {
expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(requiredPath);
}
const contentId = await computeDaytonaImageContentId();
expect(contentId).toMatch(/^[0-9a-f]{64}$/);
});
it("changes only when a selected image input or target platform changes", async () => {
const root = await mkdtemp(
path.join(tmpdir(), "paperclip-daytona-image-id-"),
);
try {
await mkdir(path.join(root, "image-input"));
await writeFile(
path.join(root, "image-input", "runner.ts"),
"version one\n",
);
const baseline = await computeDaytonaImageContentId({
repositoryRoot: root,
inputPaths: ["image-input"],
});
await writeFile(
path.join(root, "unrelated.txt"),
"does not enter the image\n",
);
expect(
await computeDaytonaImageContentId({
repositoryRoot: root,
inputPaths: ["image-input"],
}),
).toBe(baseline);
await writeFile(
path.join(root, "image-input", "runner.ts"),
"version two\n",
);
expect(
await computeDaytonaImageContentId({
repositoryRoot: root,
inputPaths: ["image-input"],
}),
).not.toBe(baseline);
expect(
await computeDaytonaImageContentId({
repositoryRoot: root,
inputPaths: ["image-input"],
platform: "linux/arm64",
}),
).not.toBe(baseline);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});