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 } from "./github"; import { parseActionManifest } from "./manifest"; import type { ActionManifest } from "./manifest"; import { isPinned, looksLikeShortSha, parseActionReference } from "./uses"; 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; }; 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 (reference.kind === "docker") throw new ActionPolicyError(`'${uses}': Docker actions are not supported yet`); 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 !== "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") throw new ActionPolicyError(`'${uses}': Docker actions are not supported yet`); if (reference.kind === "local") { const source = await localManifest(input.workspace, reference.path, uses); return { uses, local: true, repository: null, ref: null, sha: null, path: reference.path, archive: null, manifest: parseActionManifest(source, uses), }; } 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"}`); return { uses, local: false, repository: `${owner}/${repo}`, ref, sha, path, archive: { key: archive.key, sha256: archive.sha256 }, manifest: parseActionManifest(source, uses), }; } /** * 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 resolved: ResolvedActions = {}; for (const reference of uses) resolved[reference] = await resolveOne(reference, { workspace: input.workspace, billing }); return resolved; }