diff --git a/scripts/lint-composite-actions.d.mts b/scripts/lint-composite-actions.d.mts new file mode 100644 index 000000000..4d92ed6f6 --- /dev/null +++ b/scripts/lint-composite-actions.d.mts @@ -0,0 +1,31 @@ +export type SchemaValidator = ((doc: unknown) => boolean) & { + errors?: Array<{ instancePath?: string; message?: string }> | null; +}; + +export type Dirent = { name: string; isDirectory(): boolean }; + +export type ReaddirFn = (dir: string, options: { withFileTypes: true }) => Dirent[]; + +export type ReadFileFn = (path: string, encoding?: string) => string | Uint8Array; + +export declare function compileActionSchema(schemaJson: unknown): SchemaValidator; + +export declare function findActionFiles( + actionsDir: string, + deps: { readdir: ReaddirFn; readFile: ReadFileFn }, +): string[]; + +export declare function validateActionFile( + path: string, + content: string, + validateSchema: SchemaValidator, +): string[]; + +export declare function runLint(options: { + actionsDir: string; + readdir: ReaddirFn; + readFile: ReadFileFn; + validateSchema: SchemaValidator; + log?: (message: string) => void; + error?: (message: string) => void; +}): number; diff --git a/scripts/lint-composite-actions.mjs b/scripts/lint-composite-actions.mjs index 34e28ccd4..ec08ded33 100644 --- a/scripts/lint-composite-actions.mjs +++ b/scripts/lint-composite-actions.mjs @@ -22,23 +22,30 @@ import Ajv from "ajv"; import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { parse } from "yaml"; const ACTIONS_DIR = ".github/actions"; const SCHEMA_PATH = new URL("./schemas/github-action.schema.json", import.meta.url); -const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")); -const ajv = new Ajv({ allErrors: true, strict: false }); -const validateSchema = ajv.compile(schema); +/** Compile the vendored action-metadata schema into an Ajv validator. Exported (with the schema JSON + * injectable) so tests validate against the real schema without this module reading it off disk. */ +export function compileActionSchema(schemaJson) { + const ajv = new Ajv({ allErrors: true, strict: false }); + return ajv.compile(schemaJson); +} -function findActionFiles() { +/** Discover action.yml/action.yaml files, one per subdirectory. `readdir`/`readFile` are injected so this + * is testable without a real .github/actions tree; a subdirectory with neither file is silently skipped + * (the per-candidate readFile throw is the existing fallback). */ +export function findActionFiles(actionsDir, { readdir, readFile }) { const results = []; - for (const entry of readdirSync(ACTIONS_DIR, { withFileTypes: true })) { + for (const entry of readdir(actionsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; for (const name of ["action.yml", "action.yaml"]) { - const candidate = join(ACTIONS_DIR, entry.name, name); + const candidate = join(actionsDir, entry.name, name); try { - readFileSync(candidate); + readFile(candidate); results.push(candidate); break; // a directory has one action file, not both } catch { @@ -49,38 +56,64 @@ function findActionFiles() { return results; } -const actionFiles = findActionFiles(); -if (actionFiles.length === 0) { - console.log(`No composite action files found under ${ACTIONS_DIR}/ -- nothing to validate.`); - process.exit(0); -} - -let hasErrors = false; - -for (const path of actionFiles) { - const doc = parse(readFileSync(path, "utf8")); +/** Validate one action file's YAML text: JSON-schema violations, plus the composite-only rule that every + * `run:` step carries an explicit `shell:`. Returns the human-readable error lines (empty === clean). */ +export function validateActionFile(path, content, validateSchema) { + const errors = []; + const doc = parse(content); if (!validateSchema(doc)) { - hasErrors = true; - console.error(`${path}: schema violations:`); + errors.push(`${path}: schema violations:`); for (const err of validateSchema.errors ?? []) { - console.error(` ${err.instancePath || "(root)"} ${err.message}`); + errors.push(` ${err.instancePath || "(root)"} ${err.message}`); } } if (doc?.runs?.using === "composite") { for (const [index, step] of (doc.runs.steps ?? []).entries()) { if (step.run !== undefined && step.shell === undefined) { - hasErrors = true; - console.error( + errors.push( `${path}: runs.steps[${index}] ("${step.name ?? "unnamed"}") has a run: but no shell: -- required for composite action steps, unlike a top-level workflow job which defaults to bash`, ); } } } + + return errors; +} + +/** Lint every discovered composite action; returns the process exit code (0 clean / 1 violations). Pure + * aside from the injected `readdir`/`readFile`/`validateSchema` and the `log`/`error` sinks. */ +export function runLint({ actionsDir, readdir, readFile, validateSchema, log = console.log, error = console.error }) { + const actionFiles = findActionFiles(actionsDir, { readdir, readFile }); + if (actionFiles.length === 0) { + log(`No composite action files found under ${actionsDir}/ -- nothing to validate.`); + return 0; + } + + let hasErrors = false; + for (const path of actionFiles) { + const fileErrors = validateActionFile(path, readFile(path, "utf8"), validateSchema); + if (fileErrors.length > 0) { + hasErrors = true; + for (const line of fileErrors) error(line); + } + } + + if (hasErrors) return 1; + log(`Validated ${actionFiles.length} composite action file(s) against the GitHub action-metadata schema: all clean.`); + return 0; } -if (hasErrors) { - process.exit(1); +// Entrypoint guard (#7459): importing this module for tests exercises the pure validation logic with +// injected inputs; only direct invocation reads the schema/actions tree off disk and exits. +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const validateSchema = compileActionSchema(JSON.parse(readFileSync(SCHEMA_PATH, "utf8"))); + const exitCode = runLint({ + actionsDir: ACTIONS_DIR, + readdir: readdirSync, + readFile: readFileSync, + validateSchema, + }); + process.exit(exitCode); } -console.log(`Validated ${actionFiles.length} composite action file(s) against the GitHub action-metadata schema: all clean.`); diff --git a/test/unit/lint-composite-actions-script.test.ts b/test/unit/lint-composite-actions-script.test.ts new file mode 100644 index 000000000..9cef67773 --- /dev/null +++ b/test/unit/lint-composite-actions-script.test.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + compileActionSchema, + findActionFiles, + runLint, + validateActionFile, + type Dirent, +} from "../../scripts/lint-composite-actions.mjs"; + +// #7459: findActionFiles, the schema check, and the shell:-presence check only ran inside the +// disk-reading/process.exit driver. With readdir/readFile/validateSchema injected and the driver behind +// an entrypoint guard, each is now testable against the real vendored schema without a .github/actions tree. + +const schema = JSON.parse( + readFileSync(new URL("../../scripts/schemas/github-action.schema.json", import.meta.url), "utf8"), +); +const validateSchema = compileActionSchema(schema); + +const WELL_FORMED = [ + "name: my-action", + "description: does a thing", + "runs:", + " using: composite", + " steps:", + " - name: greet", + " run: echo hi", + " shell: bash", +].join("\n"); + +function dir(name: string): Dirent { + return { name, isDirectory: () => true }; +} + +describe("validateActionFile (#7459)", () => { + it("passes a well-formed composite action whose run: steps all carry a shell:", () => { + expect(validateActionFile(".github/actions/ok/action.yml", WELL_FORMED, validateSchema)).toEqual([]); + }); + + it("flags a composite run: step missing shell: with its exact index and name", () => { + const content = [ + "name: my-action", + "description: does a thing", + "runs:", + " using: composite", + " steps:", + " - name: broken", + " run: echo hi", + ].join("\n"); + const errors = validateActionFile(".github/actions/bad/action.yml", content, validateSchema); + expect(errors.some((e) => e.includes('runs.steps[0] ("broken")') && e.includes("no shell:"))).toBe(true); + }); + + it("flags a schema violation (action with no runs block at all)", () => { + const content = ["name: my-action", "description: does a thing"].join("\n"); + const errors = validateActionFile(".github/actions/noruns/action.yml", content, validateSchema); + expect(errors[0]).toContain("schema violations"); + expect(errors.length).toBeGreaterThan(1); + }); +}); + +describe("findActionFiles (#7459)", () => { + it("silently skips a subdirectory that has neither action.yml nor action.yaml", () => { + const readdir = () => [dir("has-neither")]; + const readFile = (path: string) => { + throw new Error(`ENOENT: ${path}`); + }; + expect(findActionFiles(".github/actions", { readdir, readFile })).toEqual([]); + }); + + it("finds the single action file (yml preferred) in each subdirectory", () => { + const readdir = () => [dir("a"), { name: "not-a-dir", isDirectory: () => false }]; + const readFile = (path: string) => { + if (path.endsWith("a/action.yml")) return "ok"; + throw new Error(`ENOENT: ${path}`); + }; + expect(findActionFiles(".github/actions", { readdir, readFile })).toEqual([".github/actions/a/action.yml"]); + }); +}); + +describe("runLint (#7459)", () => { + it("early-exits 0 when no action files are found", () => { + const messages: string[] = []; + const code = runLint({ + actionsDir: ".github/actions", + readdir: () => [], + readFile: () => "", + validateSchema, + log: (m) => messages.push(m), + error: () => {}, + }); + expect(code).toBe(0); + expect(messages.some((m) => m.includes("nothing to validate"))).toBe(true); + }); + + it("returns 1 and reports the error when a discovered action file is invalid", () => { + const content = [ + "name: my-action", + "description: does a thing", + "runs:", + " using: composite", + " steps:", + " - run: echo hi", + ].join("\n"); + const errors: string[] = []; + const code = runLint({ + actionsDir: ".github/actions", + readdir: () => [dir("bad")], + readFile: (path: string, encoding?: string) => { + if (encoding === "utf8") return content; + return "exists"; + }, + validateSchema, + log: () => {}, + error: (m) => errors.push(m), + }); + expect(code).toBe(1); + expect(errors.some((e) => e.includes("no shell:"))).toBe(true); + }); + + it("returns 0 for a clean discovered action file", () => { + const code = runLint({ + actionsDir: ".github/actions", + readdir: () => [dir("ok")], + readFile: (path: string, encoding?: string) => (encoding === "utf8" ? WELL_FORMED : "exists"), + validateSchema, + log: () => {}, + error: () => {}, + }); + expect(code).toBe(0); + }); +});