diff --git a/.github/actions/prepare-runner-image-release/action.yml b/.github/actions/prepare-runner-image-release/action.yml new file mode 100644 index 0000000..1ad2d11 --- /dev/null +++ b/.github/actions/prepare-runner-image-release/action.yml @@ -0,0 +1,34 @@ +# @generated by Hollywood. Do not edit by hand. + +name: Prepare runner image release +description: Validate release identity and derive canonical OCI tags. +inputs: + event: + description: GitHub event publishing the image. + required: true + image: + description: OCI image name without a tag. + required: true + package-json: + description: Package manifest that owns the release version. + required: false + default: package.json + ref: + description: Fully qualified Git ref. + required: true + ref-name: + description: Git branch or tag name. + required: true + revision: + description: Git revision embedded in the image. + required: true +outputs: + source-ref: + description: Validated source ref. + tags: + description: Newline-separated OCI tags. + version: + description: OCI image version label. +runs: + using: node24 + main: dist/index.js diff --git a/.github/actions/prepare-runner-image-release/src/index.ts b/.github/actions/prepare-runner-image-release/src/index.ts new file mode 100644 index 0000000..ea02eeb --- /dev/null +++ b/.github/actions/prepare-runner-image-release/src/index.ts @@ -0,0 +1,6 @@ +// @generated by Hollywood. Do not edit by hand. + +import { runGitHubAction } from "@dedalus-labs/hollywood/action-runtime"; +import { prepareRunnerImageRelease } from "@/gha/runner-image-actions.ts"; + +void runGitHubAction(prepareRunnerImageRelease); diff --git a/.github/actions/verify-published-runner-image/action.yml b/.github/actions/verify-published-runner-image/action.yml new file mode 100644 index 0000000..ba988f0 --- /dev/null +++ b/.github/actions/verify-published-runner-image/action.yml @@ -0,0 +1,24 @@ +# @generated by Hollywood. Do not edit by hand. + +name: Verify published runner image +description: Verify GitHub provenance and anonymous registry access. +inputs: + digest: + description: Published OCI manifest digest. + required: true + image: + description: Published OCI image name without a tag. + required: true + repository: + description: GitHub source repository. + required: true + source-digest: + description: Git source revision attested by GitHub. + required: true + source-ref: + description: Git source ref attested by GitHub. + required: true +outputs: {} +runs: + using: node24 + main: dist/index.js diff --git a/.github/actions/verify-published-runner-image/src/index.ts b/.github/actions/verify-published-runner-image/src/index.ts new file mode 100644 index 0000000..8efe55b --- /dev/null +++ b/.github/actions/verify-published-runner-image/src/index.ts @@ -0,0 +1,6 @@ +// @generated by Hollywood. Do not edit by hand. + +import { runGitHubAction } from "@dedalus-labs/hollywood/action-runtime"; +import { verifyPublishedRunnerImage } from "@/gha/runner-image-actions.ts"; + +void runGitHubAction(verifyPublishedRunnerImage); diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1744fae..cb0a0ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,10 @@ version: 2 updates: + - package-ecosystem: docker + directory: /runner + schedule: + interval: weekly + open-pull-requests-limit: 3 - package-ecosystem: npm directory: / schedule: diff --git a/.github/workflows/runner-image.yml b/.github/workflows/runner-image.yml new file mode 100644 index 0000000..15d5d0c --- /dev/null +++ b/.github/workflows/runner-image.yml @@ -0,0 +1,197 @@ +# @generated by Hollywood. Do not edit by hand. + +name: Runner Image +on: + pull_request: + branches: + - main + paths: + - runner/** + - src/container*.ts + - src/runner*.ts + - gha/actions.ts + - gha/runner-image*.ts + - .npmrc + - package.json + - package-lock.json + - tsdown.config.ts + push: + branches: + - main + paths: + - runner/** + - src/container*.ts + - src/runner*.ts + - gha/actions.ts + - gha/runner-image*.ts + - .npmrc + - package.json + - package-lock.json + - tsdown.config.ts + release: + types: + - published + schedule: + - cron: 17 7 * * 1 + workflow_dispatch: {} +concurrency: + group: runner-image-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + observe: + name: Observe (${{ matrix.architecture }}) + if: github.repository == 'dedalus-labs/hollywood' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ matrix.architecture == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }} + strategy: + matrix: + architecture: + - amd64 + - arm64 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "24" + - name: Install dependencies + run: npm ci + - name: Audit dependencies + run: npm audit --audit-level=high + - name: Verify registry signatures + run: npm audit signatures + - name: Build Hollywood + run: npm run build + - name: Build local actions + run: npm run actions + - name: Capture runner + uses: ./.github/actions/capture-runner-probe + with: + output: runner-${{ matrix.architecture }}.json + - name: Verify runner + uses: ./.github/actions/verify-runner-probe + with: + contract: runner/contract.json + probe: runner-${{ matrix.architecture }}.json + - name: Upload runner probe + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: runner-${{ matrix.architecture }} + path: runner-${{ matrix.architecture }}.json + if-no-files-found: error + retention-days: 30 + verify: + name: Verify image (${{ matrix.provider }}, ${{ matrix.architecture }}) + if: github.repository == 'dedalus-labs/hollywood' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ matrix.architecture == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }} + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + provider: + - docker + - podman + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "24" + - name: Install dependencies + run: npm ci + - name: Audit dependencies + run: npm audit --audit-level=high + - name: Verify registry signatures + run: npm audit signatures + - name: Build Hollywood + run: npm run build + - name: Build local actions + run: npm run actions + - name: Verify runner image + uses: ./.github/actions/verify-runner-image + with: + provider: ${{ matrix.provider }} + publish: + name: Publish image + needs: + - observe + - verify + if: ${{ github.repository == 'dedalus-labs/hollywood' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') }} + runs-on: ubuntu-24.04 + permissions: + attestations: write + contents: read + id-token: write + packages: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "24" + - name: Install dependencies + run: npm ci + - name: Audit dependencies + run: npm audit --audit-level=high + - name: Verify registry signatures + run: npm audit signatures + - name: Build Hollywood + run: npm run build + - name: Build local actions + run: npm run actions + - id: release + name: Prepare release + uses: ./.github/actions/prepare-runner-image-release + with: + event: ${{ github.event_name }} + image: ghcr.io/dedalus-labs/hollywood-runner + ref: ${{ github.ref }} + ref-name: ${{ github.ref_name }} + revision: ${{ github.sha }} + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + - id: build + name: Build and publish + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: runner + file: runner/Containerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.release.outputs.tags }} + labels: ${{ format('org.opencontainers.image.version={0}', steps.release.outputs.version) }} + build-args: ${{ format('SOURCE_REVISION={0}', github.sha) }} + cache-from: type=gha,scope=runner-image-main + cache-to: type=gha,mode=max,scope=runner-image-main + provenance: mode=max + sbom: true + - name: Attest image + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 + with: + subject-name: ghcr.io/dedalus-labs/hollywood-runner + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + - env: + GH_TOKEN: ${{ github.token }} + name: Verify publication + uses: ./.github/actions/verify-published-runner-image + with: + digest: ${{ steps.build.outputs.digest }} + image: ghcr.io/dedalus-labs/hollywood-runner + repository: dedalus-labs/hollywood + source-digest: ${{ github.sha }} + source-ref: ${{ steps.release.outputs.source-ref }} diff --git a/gha/actions.ts b/gha/actions.ts index 8f89b31..0b361f4 100644 --- a/gha/actions.ts +++ b/gha/actions.ts @@ -19,4 +19,22 @@ export const uploadPagesArtifactAction = export const deployPagesAction = "actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"; // v5.0.0 +export const uploadArtifactAction = + "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"; // v4 + +export const dockerLoginAction = + "docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9"; // v3 + +export const dockerSetupBuildxAction = + "docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f"; // v3 + +export const dockerSetupQemuAction = + "docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130"; // v3 + +export const dockerBuildPushAction = + "docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8"; // v6 + +export const attestBuildProvenanceAction = + "actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661"; // v3 + export const checkHollywoodStateCommand = "node dist/cli.js check"; diff --git a/gha/dogfood.test.ts b/gha/dogfood.test.ts index ebeaf15..213dc91 100644 --- a/gha/dogfood.test.ts +++ b/gha/dogfood.test.ts @@ -8,8 +8,17 @@ import { docs } from "./docs"; import { flowers } from "./flowers"; import { publishNpm } from "./publish-npm"; import { release } from "./release"; +import { runnerImageWorkflow } from "./runner-image"; -const workflows = [ci, cla, docs, flowers, publishNpm, release] satisfies readonly GitHubWorkflow[]; +const workflows = [ + ci, + cla, + docs, + flowers, + publishNpm, + release, + runnerImageWorkflow, +] satisfies readonly GitHubWorkflow[]; test("repository workflows invoke Hollywood actions as local action steps", () => { for (const workflow of workflows) { diff --git a/gha/runner-image-actions.test.ts b/gha/runner-image-actions.test.ts index 7688863..d0cb84a 100644 --- a/gha/runner-image-actions.test.ts +++ b/gha/runner-image-actions.test.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { test } from "vitest"; import { + type Command, currentRunner, githubActionsRunnerImage, nodeExec, @@ -11,7 +12,11 @@ import { runAction, type ContainerProvider, } from "../src/index"; -import { verifyRunnerImage } from "./runner-image-actions"; +import { + prepareRunnerImageRelease, + verifyPublishedRunnerImage, + verifyRunnerImage, +} from "./runner-image-actions"; test("runner image source pins its base and requires a revision", async () => { const containerfile = await readFile("runner/Containerfile", "utf8"); @@ -25,6 +30,152 @@ test("runner image source pins its base and requires a revision", async () => { assert.match(containerfile, /^ARG SOURCE_REVISION\nRUN test -n "\$\{SOURCE_REVISION\}"$/m); }); +test("runner image releases derive stable tags from the package version", async () => { + const outputs = await runAction(prepareRunnerImageRelease, { + with: { + event: "release", + image: "ghcr.io/dedalus-labs/hollywood-runner", + packageJson: "package.json", + ref: "refs/tags/v1.2.3", + refName: "v1.2.3", + revision: "0123456789abcdef0123456789abcdef01234567", + }, + exec: unexpectedExec, + fs: { readText: async () => '{"version":"1.2.3"}' }, + runner: currentRunner(), + }); + + assert.deepEqual(outputs, { + sourceRef: "refs/tags/v1.2.3", + tags: [ + "ghcr.io/dedalus-labs/hollywood-runner:sha-0123456789abcdef0123456789abcdef01234567", + "ghcr.io/dedalus-labs/hollywood-runner:1.2.3", + "ghcr.io/dedalus-labs/hollywood-runner:1.2", + "ghcr.io/dedalus-labs/hollywood-runner:latest", + "ghcr.io/dedalus-labs/hollywood-runner:ubuntu-24.04", + ].join("\n"), + version: "1.2.3", + }); +}); + +test("runner image main builds only move the development channel", async () => { + const revision = "0123456789abcdef0123456789abcdef01234567"; + const outputs = await runAction(prepareRunnerImageRelease, { + with: { + event: "push", + image: "ghcr.io/dedalus-labs/hollywood-runner", + packageJson: "package.json", + ref: "refs/heads/main", + refName: "main", + revision, + }, + exec: unexpectedExec, + fs: { readText: async () => '{"version":"1.2.3"}' }, + runner: currentRunner(), + }); + + assert.deepEqual(outputs, { + sourceRef: "refs/heads/main", + tags: [ + `ghcr.io/dedalus-labs/hollywood-runner:sha-${revision}`, + "ghcr.io/dedalus-labs/hollywood-runner:edge", + ].join("\n"), + version: `sha-${revision}`, + }); +}); + +test("runner image releases reject tags that disagree with package.json", async () => { + await assert.rejects( + runAction(prepareRunnerImageRelease, { + with: { + event: "release", + image: "ghcr.io/dedalus-labs/hollywood-runner", + packageJson: "package.json", + ref: "refs/tags/v1.2.4", + refName: "v1.2.4", + revision: "0123456789abcdef0123456789abcdef01234567", + }, + exec: unexpectedExec, + fs: { readText: async () => '{"version":"1.2.3"}' }, + runner: currentRunner(), + }), + /GitHub release tag v1\.2\.4 does not match package version 1\.2\.3/, + ); +}); + +test("prerelease runner images never move stable aliases", async () => { + const revision = "0123456789abcdef0123456789abcdef01234567"; + const outputs = await runAction(prepareRunnerImageRelease, { + with: { + event: "release", + image: "ghcr.io/dedalus-labs/hollywood-runner", + packageJson: "package.json", + ref: "refs/tags/v1.3.0-rc.1", + refName: "v1.3.0-rc.1", + revision, + }, + exec: unexpectedExec, + fs: { readText: async () => '{"version":"1.3.0-rc.1"}' }, + runner: currentRunner(), + }); + + assert.equal( + outputs.tags, + [ + `ghcr.io/dedalus-labs/hollywood-runner:sha-${revision}`, + "ghcr.io/dedalus-labs/hollywood-runner:1.3.0-rc.1", + ].join("\n"), + ); +}); + +test("published runner verification uses structured commands", async () => { + const commands: Command[] = []; + await runAction(verifyPublishedRunnerImage, { + with: { + digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + image: "ghcr.io/dedalus-labs/hollywood-runner", + repository: "dedalus-labs/hollywood", + sourceDigest: "0123456789abcdef0123456789abcdef01234567", + sourceRef: "refs/tags/v1.2.3", + }, + exec: async (file, args, options) => { + commands.push({ file, args, ...options }); + return { exitCode: 0, stderr: "", stdout: "" }; + }, + fs: nodeFs, + runner: currentRunner(), + }); + + assert.deepEqual(commands, [ + { + file: "gh", + args: [ + "attestation", + "verify", + "oci://ghcr.io/dedalus-labs/hollywood-runner@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "--repo", + "dedalus-labs/hollywood", + "--signer-workflow", + "dedalus-labs/hollywood/.github/workflows/runner-image.yml", + "--source-digest", + "0123456789abcdef0123456789abcdef01234567", + "--source-ref", + "refs/tags/v1.2.3", + "--bundle-from-oci", + "--deny-self-hosted-runners", + ], + }, + { file: "docker", args: ["logout", "ghcr.io"] }, + { + file: "docker", + args: [ + "pull", + "ghcr.io/dedalus-labs/hollywood-runner@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ], + }, + ]); +}); + const provider = process.env["HOLLYWOOD_RUNNER_IMAGE_PROVIDER"]; const realImageTest = provider === undefined ? test.skip : test; @@ -63,3 +214,7 @@ const restoreVariable = (name: string, value: string | undefined): void => { } process.env[name] = value; }; + +const unexpectedExec = async (): Promise => { + throw new Error("exec was not expected"); +}; diff --git a/gha/runner-image-actions.ts b/gha/runner-image-actions.ts index 0acd126..66d78c1 100644 --- a/gha/runner-image-actions.ts +++ b/gha/runner-image-actions.ts @@ -3,18 +3,14 @@ import { choiceInput, pathInput, stringInput, + stringOutput, type ActionInputValues, type ScriptExec, type ScriptLog, } from "../src/index"; import { withLocalContainer, type ContainerProvider } from "../src/container"; import { verifyRunner } from "../src/runner-contract"; -import { - probeRunner, - readRunnerContract, - readRunnerProbe, - writeRunnerProbe, -} from "../src/runner"; +import { probeRunner, readRunnerContract, readRunnerProbe, writeRunnerProbe } from "../src/runner"; export const captureRunnerProbe = action({ name: "Capture runner probe", @@ -51,6 +47,117 @@ export const verifyRunnerProbe = action({ }, }); +const prepareRunnerImageReleaseInputs = { + event: choiceInput({ + description: "GitHub event publishing the image.", + options: ["push", "release"] as const, + }), + image: stringInput({ description: "OCI image name without a tag." }), + packageJson: pathInput({ + description: "Package manifest that owns the release version.", + default: "package.json", + }), + ref: stringInput({ description: "Fully qualified Git ref." }), + refName: stringInput({ description: "Git branch or tag name." }), + revision: stringInput({ description: "Git revision embedded in the image." }), +} as const; + +export const prepareRunnerImageRelease = action({ + name: "Prepare runner image release", + description: "Validate release identity and derive canonical OCI tags.", + localActionPath: "prepare-runner-image-release", + inputs: prepareRunnerImageReleaseInputs, + outputs: { + sourceRef: stringOutput({ description: "Validated source ref." }), + tags: stringOutput({ description: "Newline-separated OCI tags." }), + version: stringOutput({ description: "OCI image version label." }), + }, + run: async ({ fs, input }) => { + assertImageName(input.image); + assertRevision(input.revision); + const packageJson = parseJson(await fs.readText(input.packageJson), input.packageJson); + const version = parseImageVersion(requiredString(packageJson, "version", input.packageJson)); + const immutableTag = `${input.image}:sha-${input.revision}`; + + if (input.event === "push") { + if (input.ref !== "refs/heads/main" || input.refName !== "main") { + throw new Error(`runner image push must target refs/heads/main, received ${input.ref}`); + } + return { + sourceRef: input.ref, + tags: [immutableTag, `${input.image}:edge`].join("\n"), + version: `sha-${input.revision}`, + }; + } + + const expectedTag = `v${version.value}`; + if (input.ref !== `refs/tags/${input.refName}`) { + throw new Error(`GitHub release ref ${input.ref} does not contain tag ${input.refName}`); + } + if (input.refName !== expectedTag) { + throw new Error( + `GitHub release tag ${input.refName} does not match package version ${version.value}`, + ); + } + + const tags = [immutableTag, `${input.image}:${version.value}`]; + if (!version.prerelease) { + tags.push( + `${input.image}:${version.major}.${version.minor}`, + `${input.image}:latest`, + `${input.image}:ubuntu-24.04`, + ); + } + return { + sourceRef: input.ref, + tags: tags.join("\n"), + version: version.value, + }; + }, +}); + +const verifyPublishedRunnerImageInputs = { + digest: stringInput({ description: "Published OCI manifest digest." }), + image: stringInput({ description: "Published OCI image name without a tag." }), + repository: stringInput({ description: "GitHub source repository." }), + sourceDigest: stringInput({ description: "Git source revision attested by GitHub." }), + sourceRef: stringInput({ description: "Git source ref attested by GitHub." }), +} as const; + +export const verifyPublishedRunnerImage = action({ + name: "Verify published runner image", + description: "Verify GitHub provenance and anonymous registry access.", + localActionPath: "verify-published-runner-image", + inputs: verifyPublishedRunnerImageInputs, + outputs: {}, + run: async ({ exec, input }) => { + assertImageName(input.image); + assertDigest(input.digest); + assertRevision(input.sourceDigest); + assertRepository(input.repository); + assertSourceRef(input.sourceRef); + const subject = `${input.image}@${input.digest}`; + await exec("gh", [ + "attestation", + "verify", + `oci://${subject}`, + "--repo", + input.repository, + "--signer-workflow", + `${input.repository}/.github/workflows/runner-image.yml`, + "--source-digest", + input.sourceDigest, + "--source-ref", + input.sourceRef, + "--bundle-from-oci", + "--deny-self-hosted-runners", + ]); + await exec("docker", ["logout", "ghcr.io"]); + await exec("docker", ["pull", subject]); + return {}; + }, +}); + const verifyRunnerImageInputs = { containerfile: pathInput({ description: "Runner image Containerfile.", @@ -177,13 +284,8 @@ const withBuiltImage = async ( } }; -const removeImageArgs = ( - provider: ContainerProvider, - image: string, -): readonly string[] => - provider === "container" - ? ["image", "delete", image] - : ["image", "rm", "--force", image]; +const removeImageArgs = (provider: ContainerProvider, image: string): readonly string[] => + provider === "container" ? ["image", "delete", image] : ["image", "rm", "--force", image]; const requiredVariable = (name: string): string => { const value = process.env[name]; @@ -192,3 +294,73 @@ const requiredVariable = (name: string): string => { } return value; }; + +type ImageVersion = Readonly<{ + major: string; + minor: string; + prerelease: boolean; + value: string; +}>; + +const parseImageVersion = (value: string): ImageVersion => { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value); + if (match === null) { + throw new Error(`package version must be release-tag-compatible semver: ${value}`); + } + return { + major: match[1] as string, + minor: match[2] as string, + prerelease: match[4] !== undefined, + value, + }; +}; + +const parseJson = (source: string, path: string): unknown => { + try { + return JSON.parse(source) as unknown; + } catch (error: unknown) { + throw new Error(`${path} is not valid JSON`, { cause: error }); + } +}; + +const requiredString = (value: unknown, key: string, source: string): string => { + if (value === null || typeof value !== "object") { + throw new Error(`${source} must contain a JSON object`); + } + const field = (value as Record)[key]; + if (typeof field !== "string" || field.length === 0) { + throw new Error(`${source} ${key} is required`); + } + return field; +}; + +const assertImageName = (value: string): void => { + if (!/^ghcr\.io\/[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(value)) { + throw new Error(`runner image must be an untagged GHCR image name: ${value}`); + } +}; + +const assertDigest = (value: string): void => { + if (!/^sha256:[0-9a-f]{64}$/.test(value)) { + throw new Error(`runner image digest must be sha256: ${value}`); + } +}; + +const assertRevision = (value: string): void => { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) { + throw new Error(`source revision must be a full Git object ID: ${value}`); + } +}; + +const assertRepository = (value: string): void => { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) { + throw new Error(`GitHub repository must be owner/name: ${value}`); + } +}; + +const assertSourceRef = (value: string): void => { + if (!/^refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+$/.test(value)) { + throw new Error(`source ref must be a branch or tag ref: ${value}`); + } +}; diff --git a/gha/runner-image.test.ts b/gha/runner-image.test.ts new file mode 100644 index 0000000..5ba6825 --- /dev/null +++ b/gha/runner-image.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { runnerImageWorkflow } from "./runner-image"; + +const publishJob = () => { + const publish = runnerImageWorkflow.jobs.publish; + assert.ok(publish !== undefined && "steps" in publish); + return publish; +}; + +test("runner image publication is isolated from pull request permissions", () => { + assert.deepEqual(runnerImageWorkflow.permissions, { contents: "read" }); + assert.deepEqual(publishJob().permissions, { + attestations: "write", + contents: "read", + "id-token": "write", + packages: "write", + }); + assert.equal( + publishJob().if, + "${{ github.repository == 'dedalus-labs/hollywood' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') }}", + ); +}); + +test("runner image publication pins every third-party action", () => { + for (const step of publishJob().steps) { + if ("uses" in step && !step.uses.startsWith("./")) { + assert.match(step.uses, /@[0-9a-f]{40}$/); + } + } +}); + +test("runner image publication emits SBOM and signed provenance", () => { + const build = publishJob().steps.find((step) => "id" in step && step.id === "build"); + assert.ok(build !== undefined && "uses" in build && "with" in build); + assert.ok("sbom" in build.with && "provenance" in build.with); + assert.equal(build.with.sbom, true); + assert.equal(build.with.provenance, "mode=max"); + + const attest = publishJob().steps.find( + (step) => "uses" in step && step.uses.includes("attest-build-provenance"), + ); + assert.ok(attest !== undefined && "uses" in attest && "with" in attest); + assert.ok("push-to-registry" in attest.with); + assert.equal(attest.with["push-to-registry"], true); +}); + +test("runner image publication proves signed and anonymous access", () => { + const verification = publishJob().steps.at(-1); + assert.ok(verification !== undefined && "uses" in verification); + assert.equal(verification.uses, "./.github/actions/verify-published-runner-image"); + assert.deepEqual(verification.with, { + digest: "${{ steps.build.outputs.digest }}", + image: "ghcr.io/dedalus-labs/hollywood-runner", + repository: "dedalus-labs/hollywood", + "source-digest": "${{ github.sha }}", + "source-ref": "${{ steps.release.outputs.source-ref }}", + }); +}); + +test("runner image verification covers Docker and Podman natively on amd64 and arm64", () => { + const verify = runnerImageWorkflow.jobs.verify; + assert.ok(verify !== undefined && "steps" in verify); + assert.deepEqual(Object.getOwnPropertyDescriptor(verify.strategy?.matrix, "values")?.value, { + architecture: ["amd64", "arm64"], + provider: ["docker", "podman"], + }); +}); + +test("runner image publication handles main and matching GitHub releases", () => { + assert.deepEqual(runnerImageWorkflow.on.release, { types: ["published"] }); + assert.equal( + publishJob().if, + "${{ github.repository == 'dedalus-labs/hollywood' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') }}", + ); + + const release = publishJob().steps.find((step) => "id" in step && step.id === "release"); + assert.ok(release !== undefined && "uses" in release); + assert.equal(release.uses, "./.github/actions/prepare-runner-image-release"); + + const build = publishJob().steps.find((step) => "id" in step && step.id === "build"); + assert.ok(build !== undefined && "with" in build); + assert.ok("tags" in build.with && "labels" in build.with); + assert.equal(build.with.tags, "${{ steps.release.outputs.tags }}"); + assert.equal( + build.with.labels, + "${{ format('org.opencontainers.image.version={0}', steps.release.outputs.version) }}", + ); +}); + +test("runner image workflow contains no unstructured publication commands", () => { + const built = publishJob().steps.findIndex( + (step) => "name" in step && step.name === "Build local actions", + ); + assert.notEqual(built, -1); + for (const step of publishJob().steps.slice(built + 1)) { + const name = "name" in step ? step.name : "unnamed"; + assert.ok(!("run" in step), `publish step must use an action: ${name}`); + } +}); diff --git a/gha/runner-image.ts b/gha/runner-image.ts new file mode 100644 index 0000000..88d2b5f --- /dev/null +++ b/gha/runner-image.ts @@ -0,0 +1,205 @@ +import { + and, + defineMatrix, + eq, + format, + gh, + job, + or, + selectString, + stepOutput, + uses, + workflow, +} from "../src/index"; +import { + attestBuildProvenanceAction, + checkoutAction, + dockerBuildPushAction, + dockerLoginAction, + dockerSetupBuildxAction, + dockerSetupQemuAction, + setupNodeAction, + uploadArtifactAction, +} from "./actions"; +import { trustedCiRun } from "./guards"; +import { + captureRunnerProbe, + prepareRunnerImageRelease, + verifyPublishedRunnerImage, + verifyRunnerImage, + verifyRunnerProbe, +} from "./runner-image-actions"; + +const runnerImage = "ghcr.io/dedalus-labs/hollywood-runner"; +const runnerImagePaths = [ + "runner/**", + "src/container*.ts", + "src/runner*.ts", + "gha/actions.ts", + "gha/runner-image*.ts", + ".npmrc", + "package.json", + "package-lock.json", + "tsdown.config.ts", +] as const; +const runnerArchitectures = defineMatrix({ architecture: ["amd64", "arm64"] } as const); +const imageProviders = defineMatrix({ + architecture: ["amd64", "arm64"], + provider: ["docker", "podman"], +} as const); +const runnerForArchitecture = (architecture: typeof runnerArchitectures.architecture) => + selectString(eq(architecture, "amd64"), "ubuntu-24.04", "ubuntu-24.04-arm"); +const setupSteps = [ + { uses: checkoutAction, with: { "persist-credentials": false } }, + { uses: setupNodeAction, with: { "node-version": "24" } }, + { name: "Install dependencies", run: "npm ci" }, + { name: "Audit dependencies", run: "npm audit --audit-level=high" }, + { name: "Verify registry signatures", run: "npm audit signatures" }, + { name: "Build Hollywood", run: "npm run build" }, + { name: "Build local actions", run: "npm run actions" }, +] as const; + +export const runnerImageWorkflow = workflow({ + name: "Runner Image", + on: { + pull_request: { branches: ["main"], paths: runnerImagePaths }, + push: { branches: ["main"], paths: runnerImagePaths }, + release: { types: ["published"] }, + schedule: [{ cron: "17 7 * * 1" }], + workflow_dispatch: {}, + }, + concurrency: { + group: "runner-image-${{ github.ref }}", + "cancel-in-progress": true, + }, + permissions: { contents: "read" }, + jobs: { + observe: job({ + name: "Observe (${{ matrix.architecture }})", + if: trustedCiRun, + "runs-on": runnerForArchitecture(runnerArchitectures.architecture), + strategy: { matrix: runnerArchitectures }, + steps: [ + ...setupSteps, + uses(captureRunnerProbe, { + name: "Capture runner", + with: { output: "runner-${{ matrix.architecture }}.json" }, + }), + uses(verifyRunnerProbe, { + name: "Verify runner", + with: { + contract: "runner/contract.json", + probe: "runner-${{ matrix.architecture }}.json", + }, + }), + { + name: "Upload runner probe", + uses: uploadArtifactAction, + with: { + name: "runner-${{ matrix.architecture }}", + path: "runner-${{ matrix.architecture }}.json", + "if-no-files-found": "error", + "retention-days": 30, + }, + }, + ], + }), + verify: job({ + name: "Verify image (${{ matrix.provider }}, ${{ matrix.architecture }})", + if: trustedCiRun, + "runs-on": runnerForArchitecture(imageProviders.architecture), + strategy: { "fail-fast": false, matrix: imageProviders }, + steps: [ + ...setupSteps, + uses(verifyRunnerImage, { + name: "Verify runner image", + with: { provider: imageProviders.provider }, + }), + ], + }), + publish: job({ + name: "Publish image", + needs: ["observe", "verify"], + if: and( + eq(gh.github.repository, "dedalus-labs/hollywood"), + or( + and(eq(gh.github.eventName, "push"), eq(gh.github.ref, "refs/heads/main")), + eq(gh.github.eventName, "release"), + ), + ), + "runs-on": "ubuntu-24.04", + permissions: { + attestations: "write", + contents: "read", + "id-token": "write", + packages: "write", + }, + steps: [ + ...setupSteps, + uses(prepareRunnerImageRelease, { + id: "release", + name: "Prepare release", + with: { + event: gh.github.eventName, + image: runnerImage, + ref: gh.github.ref, + refName: gh.github.refName, + revision: gh.github.sha, + }, + }), + { + name: "Log in to GHCR", + uses: dockerLoginAction, + with: { + registry: "ghcr.io", + username: gh.github.actor, + password: gh.github.token, + }, + }, + { name: "Set up QEMU", uses: dockerSetupQemuAction }, + { name: "Set up Buildx", uses: dockerSetupBuildxAction }, + { + id: "build", + name: "Build and publish", + uses: dockerBuildPushAction, + with: { + context: "runner", + file: "runner/Containerfile", + platforms: "linux/amd64,linux/arm64", + push: true, + tags: stepOutput("release", "tags"), + labels: format( + "org.opencontainers.image.version={0}", + stepOutput("release", "version"), + ), + "build-args": format("SOURCE_REVISION={0}", gh.github.sha), + "cache-from": "type=gha,scope=runner-image-main", + "cache-to": "type=gha,mode=max,scope=runner-image-main", + provenance: "mode=max", + sbom: true, + }, + }, + { + name: "Attest image", + uses: attestBuildProvenanceAction, + with: { + "subject-name": runnerImage, + "subject-digest": stepOutput("build", "digest"), + "push-to-registry": true, + }, + }, + uses(verifyPublishedRunnerImage, { + name: "Verify publication", + env: { GH_TOKEN: gh.github.token }, + with: { + digest: stepOutput("build", "digest"), + image: runnerImage, + repository: "dedalus-labs/hollywood", + sourceDigest: gh.github.sha, + sourceRef: stepOutput("release", "source-ref"), + }, + }), + ], + }), + }, +});