Files
Jamie Tanna 04d4aecc43 feat(manager/github-actions): extract runs.image in Docker container actions (#47423)
When a GitHub Action uses a pre-built Docker container in its
`runs.image`, we didn't previously extract + suggest updates.

As Actions should really be digest pinning these container images, we
should make it easier for maintainers to keep these updated by
extracting + updating it.

We only need to extract this when there's a `docker://` prefix, and
ignore local i.e. `Dockerfile` references.

As the runner matches the `docker://` prefix case-insensitively[0], we
do the same, although this doesn't change the existing (case-sensitive)
handling of `uses: docker://...`.

[0]: https://github.com/actions/runner/blob/7ace83d7266e6c00c00883755821392e171dc51c/src/Runner.Worker/ActionManager.cs#L1467

Co-authored-by: Claude Opus 5.5 <jamie.tanna+amp@mend.io>
2026-10-26 16:59:47 +00:00

114 lines
3.6 KiB
TypeScript

import { z } from 'zod/v4';
import {
Json,
LooseArray,
LooseRecord,
Yaml,
withDebugMessage,
} from '../../../util/schema-utils/index.ts';
import { actionSchema, knownActions } from './known-actions.ts';
import type { ActionSchema } from './types.ts';
const UsesStep = z.object({
uses: z.string(),
with: LooseRecord(
z.union([z.string(), z.number().transform((s) => s.toString())]),
),
});
export type UsesStep = z.infer<typeof UsesStep>;
// A `parallel:` group flattens its (recursively resolved) sub-steps into leaf steps.
// See: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-running-steps-in-parallel
const ParallelStep: z.ZodType<UsesStep[]> = z
.object({ parallel: LooseArray(z.lazy(() => Step)) })
.transform(({ parallel }) => parallel.flat());
// A step resolves to a flat list of `uses:` steps: a normal step yields itself,
// a `parallel:` group yields its flattened sub-steps.
const Step: z.ZodType<UsesStep[]> = z.union([
UsesStep.transform((step) => [step]),
ParallelStep,
]);
// Flatten the per-element `UsesStep[]` groups into a single `UsesStep[]`.
const Steps = LooseArray(Step).transform((groups) => groups.flat());
const WorkFlowJobs = z.object({
jobs: LooseRecord(
z.object({
container: z
.union([
z.string(),
z.object({ image: z.string() }).transform((v) => v.image),
])
.optional()
.catch(undefined),
services: LooseRecord(
z.union([
z.object({ image: z.string() }).transform((v) => v.image),
z.string(),
]),
)
.catch({})
.transform((services) => Object.values(services)),
'runs-on': z
.union([z.string().transform((v) => [v]), z.array(z.string())])
.catch([]),
steps: Steps.catch([]),
}),
),
});
const Actions = z.object({
runs: z.object({
using: z.string(),
steps: Steps.optional().catch([]),
// a Docker container action: either a `Dockerfile` path, or a `docker://` image
image: z.string().optional().catch(undefined),
}),
});
export const Workflow = Yaml.pipe(
z.union([WorkFlowJobs, Actions, z.null()]),
).catch(withDebugMessage(null, 'Does not match schema'));
/**
* The `actions.lock` schema is owned by the `gh actions-lock` CLI, and is still unstable, so don't rely on the structure too heavily.
*
* `workflows` is deliberately required: if the tool renames or reshapes it, we want the parse to fail so that we stop touching the lockfile, rather than silently treating every workflow as un-onboarded.
*
* TODO #45191: pin to a version of the schema, once it is v1
*/
export const ActionsLockfile = Yaml.pipe(
z.object({
workflows: z.record(z.string(), z.unknown()),
}),
);
export type ActionsLockfile = z.infer<typeof ActionsLockfile>;
/**
* The error findings of `gh actions-lock --json=findings`, as written to stdout.
*/
export const FindingsOutput = Json.pipe(
z.object({
findings: LooseArray(
z.object({
severity: z.string(),
workflow: z.string(),
// omitted for findings about the workflow as a whole, like `invalid-workflow`
dependency: z.string().optional(),
detail: z.string(),
remediation: z.string().optional(),
}),
),
}),
).transform(({ findings }) =>
findings.filter(({ severity }) => severity === 'error'),
);
export type Finding = z.infer<typeof FindingsOutput>[number];
export const CommunityActions = z.union(
Object.entries(knownActions).map(([name, cfg]) =>
actionSchema(name, cfg),
) as [ActionSchema, ActionSchema, ...ActionSchema[]],
);