import { withStore } from "../../git/store"; import type { ActionsPolicy, RepoRow } from "~/lib/types"; import { isBuiltinAction } from "../builtin-actions"; import type { WorkflowSpec } from "../spec"; import { archiveKey, archiveManifest, ensureArchive, resolveActionRef, verifyPin } from "./archive"; import { ActionSourceError, withGithubToken } from "./github"; import { loadSecretRows } from "../config-values"; import { decryptRepoSecret } from "../../secrets"; import { GITHUB_COM_TOKEN_SECRET } from "./compile-action"; import { parseActionManifest } from "./manifest"; import type { ActionManifest } from "./manifest"; import { isPinned, looksLikeShortSha, parseActionReference } from "./uses"; import { isDigestPinned, parseImageRef, resolveImage } from "./images"; import type { ResolvedImage } from "./images"; import { enforceRateLimit } from "../../limits"; const MAX_ACTIONS_PER_WORKFLOW = 25; const MAX_MANIFEST_BYTES = 256 * 1024; const MANIFEST_NAMES = ["action.yml", "action.yaml"]; /** A `uses:` resolved at scheduling time and stored with the run, so recompiles and retries never refetch. */ export type ResolvedAction = { uses: string; local: boolean; /** `owner/repo` as written, for logs and `GITHUB_ACTION_REPOSITORY`. */ repository: string | null; ref: string | null; sha: string | null; /** Directory of action.yml inside the archive (remote) or the workspace (local); '' is the root. */ path: string; archive: { key: string; sha256: string } | null; manifest: ActionManifest; /** The registry image a Docker action runs, pinned to a digest when the run was scheduled. */ image?: ResolvedImage | null; }; export type ResolvedActions = Record; export class ActionPolicyError extends Error {} /** Every distinct non-builtin `uses:` in a workflow. */ export function workflowActionUses(spec: WorkflowSpec): string[] { const uses = new Set(); for (const job of Object.values(spec.jobs)) { for (const step of job.steps) { if (step.uses !== undefined && !isBuiltinAction(step)) uses.add(step.uses.trim()); } } return [...uses]; } /** Rejects references the repository's policy doesn't allow, before anything is downloaded. */ export function checkPolicy(uses: string, policy: ActionsPolicy): void { const reference = parseActionReference(uses); if (policy === "builtin") { throw new ActionPolicyError( `'${uses}': this repository only allows built-in actions (checkout, cache, upload-artifact, setup-node, setup-bun)`, ); } if (reference.kind === "docker") { parseImageRef(reference.image); if (policy === "pinned" && !isDigestPinned(reference.image)) { throw new ActionPolicyError(`'${uses}': this repository requires images pinned by digest (@sha256:...)`); } return; } if (reference.kind !== "remote") return; if (looksLikeShortSha(reference.ref) && !isPinned(reference.ref)) { throw new ActionPolicyError(`'${uses}': short commit SHAs are ambiguous; use the full 40-character SHA`); } if (policy === "pinned" && !isPinned(reference.ref)) { throw new ActionPolicyError(`'${uses}': this repository requires actions pinned to a full commit SHA`); } } async function localManifest(source: { repo: RepoRow; sha: string }, path: string, uses: string) { return withStore(source.repo.artifacts_name, async (store) => { const commit = await store.commit(source.sha); if (!commit) throw new ActionSourceError(`'${uses}': the workflow commit is missing`); for (const name of MANIFEST_NAMES) { const entry = await store.entryAtPath(commit.treeSha, path ? `${path}/${name}` : name); if (!entry || entry.type === "tree") continue; const bytes = await store.blob(entry.sha); if (bytes && bytes.byteLength <= MAX_MANIFEST_BYTES) return new TextDecoder().decode(bytes); } throw new ActionSourceError(`'${uses}': no action.yml in ${path || "the repository root"}`); }); } async function resolveOne( uses: string, input: { workspace: { repo: RepoRow; sha: string }; billing: { accountId: string; repoId: string; runId: string }; }, ): Promise { const reference = parseActionReference(uses); if (reference.kind === "docker") return dockerStep(uses, reference.image, input.billing.accountId); if (reference.kind === "local") { const manifest = parseActionManifest(await localManifest(input.workspace, reference.path, uses), uses); return { uses, local: true, repository: null, ref: null, sha: null, path: reference.path, archive: null, manifest, image: await manifestImage(manifest, input.billing.accountId), }; } const { owner, repo, path, ref } = reference; let sha: string; if (isPinned(ref)) { await verifyPin(owner, repo, ref); sha = ref; } else { sha = await resolveActionRef(owner, repo, ref); } const archive = await ensureArchive(owner, repo, sha, input.billing); const source = await archiveManifest(archiveKey(owner, repo, sha), path); if (!source) throw new ActionSourceError(`'${uses}': no action.yml in ${path || "the repository root"}`); const manifest = parseActionManifest(source, uses); return { uses, local: false, repository: `${owner}/${repo}`, ref, sha, path, archive: { key: archive.key, sha256: archive.sha256 }, manifest, image: await manifestImage(manifest, input.billing.accountId), }; } async function pinImage(ref: string, accountId: string): Promise { await enforceRateLimit("HEAVY_LIMITER", `action-image:${accountId}`); return resolveImage(ref); } /** A Docker action's `docker://` image, pinned to a digest; Dockerfile actions are built on the runner. */ async function manifestImage(manifest: ActionManifest, accountId: string): Promise { const image = manifest.docker?.image; return image?.kind === "registry" ? pinImage(image.ref, accountId) : null; } /** `uses: docker://image` runs the image itself; `with.args` and `with.entrypoint` override its command. */ async function dockerStep(uses: string, ref: string, accountId: string): Promise { return { uses, local: false, repository: null, ref: null, sha: null, path: "", archive: null, manifest: { name: uses, runtime: "docker", main: "", docker: { image: { kind: "registry", ref }, entrypoint: null, preEntrypoint: null, postEntrypoint: null, args: [], env: [], }, pre: null, post: null, preIf: null, postIf: null, inputs: [], }, image: await pinImage(ref, accountId), }; } /** * Resolves every third-party action a workflow uses: tags and branches become commit SHAs, pinned SHAs are * checked to belong to the named repository, and each commit's archive is downloaded (once, platform-wide) * and verified. Throws a user-facing error that becomes the run's compile error. */ export async function resolveWorkflowActions(input: { spec: WorkflowSpec; repo: RepoRow; workspace: { repo: RepoRow; sha: string }; runId: string; }): Promise { const uses = workflowActionUses(input.spec); if (uses.length === 0) return {}; if (uses.length > MAX_ACTIONS_PER_WORKFLOW) { throw new ActionPolicyError(`a workflow may use at most ${MAX_ACTIONS_PER_WORKFLOW} different actions`); } const policy = input.repo.actions_policy ?? "all"; for (const reference of uses) checkPolicy(reference, policy); const billing = { accountId: input.repo.owner_id, repoId: input.repo.id, runId: input.runId }; const token = await githubComToken(input.repo); return withGithubToken(token, async () => { const resolved: ResolvedActions = {}; for (const reference of uses) resolved[reference] = await resolveOne(reference, { workspace: input.workspace, billing }); return resolved; }); } /** * The repository's (or its owner's) GITHUB_COM_TOKEN, used only by the forge to resolve and download public * actions from github.com; it is never sent to the job by this path. */ async function githubComToken(repo: RepoRow): Promise { const row = (await loadSecretRows(repo, null)).find((candidate) => candidate.name === GITHUB_COM_TOKEN_SECRET); return row ? decryptRepoSecret(row.ciphertext, row.iv).catch(() => null) : null; }