Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions scripts/lint-composite-actions.d.mts
Original file line number Diff line number Diff line change
@@ -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;
83 changes: 58 additions & 25 deletions scripts/lint-composite-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.`);
133 changes: 133 additions & 0 deletions test/unit/lint-composite-actions-script.test.ts
Original file line number Diff line number Diff line change
@@ -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"),

Check failure on line 17 in test/unit/lint-composite-actions-script.test.ts

View workflow job for this annotation

GitHub Actions / validate-code

No overload matches this call.
);
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);
});
});
Loading