diff --git a/.changeset/thirty-turkeys-float.md b/.changeset/thirty-turkeys-float.md new file mode 100644 index 000000000..0981df2a5 --- /dev/null +++ b/.changeset/thirty-turkeys-float.md @@ -0,0 +1,5 @@ +--- +"advanced-triggers": major +--- + +feat: initial release diff --git a/actions/advanced-triggers/README.md b/actions/advanced-triggers/README.md index 0bf20d699..62ce4a99d 100644 --- a/actions/advanced-triggers/README.md +++ b/actions/advanced-triggers/README.md @@ -96,12 +96,36 @@ from consideration_ before any positive matching occurs. ## Inputs -| Input | Required | Default | Description | -| ----------------- | -------- | ------------------------- | ---------------------------------------------------------------- | -| `github-token` | yes | `${{ github.token }}` | GitHub token for API access (defaults to the built-in token) | -| `repository-root` | no | `${{ github.workspace }}` | Repo root directory, used for git-based diff on push/merge_group | -| `file-sets` | no | — | YAML string of named file-set pattern groups (see below) | -| `triggers` | yes | — | YAML string of named triggers (see below) | +| Input | Required | Default | Description | +| ----------------- | -------- | ------------------------- | ----------------------------------------------------------------------------------- | +| `github-token` | yes | `${{ github.token }}` | GitHub token for API access | +| `repository-root` | no | `${{ github.workspace }}` | Repo root directory, used for git-based diff on push/merge_group | +| `force-all` | no | `"false"` | When `"true"`, skips all filtering and outputs `true` for every trigger (see below) | +| `file-sets` | no | — | YAML string of named file-set pattern groups (see below) | +| `triggers` | yes | — | YAML string of named triggers (see below) | + +### `force-all` + +When `force-all` is `"true"`, the action skips file-change detection and trigger +evaluation entirely and outputs `true` for every configured trigger. This is +useful when you want all jobs to run unconditionally regardless of what changed +— for example on a release branch push or a tag push: + +```yaml +- id: filter + uses: smartcontractkit/.github/actions/advanced-triggers@main + with: + force-all: + ${{ startsWith(github.ref, 'refs/tags/') || startsWith(github.ref, + 'refs/heads/release/') }} + file-sets: | + ... + triggers: | + ... +``` + +The trigger names are still parsed so their outputs are set correctly — only the +evaluation logic is bypassed. ## Outputs diff --git a/actions/advanced-triggers/action.yml b/actions/advanced-triggers/action.yml index 932e8ec50..a11ac7e41 100644 --- a/actions/advanced-triggers/action.yml +++ b/actions/advanced-triggers/action.yml @@ -24,6 +24,15 @@ inputs: "exclusion-sets" (excluded before positive matching). required: false + force-all: + description: | + When set to "true", skips all file-change detection and trigger evaluation + and outputs true for every configured trigger. Use this to unconditionally + run all jobs — for example when pushing to a release branch or a tag. + Defaults to "false". + default: "false" + required: false + triggers: description: | A YAML string defining named triggers. Each trigger is a mapping with optional diff --git a/actions/advanced-triggers/dist/index.js b/actions/advanced-triggers/dist/index.js index 679067d6b..8a82fda3f 100644 --- a/actions/advanced-triggers/dist/index.js +++ b/actions/advanced-triggers/dist/index.js @@ -33684,6 +33684,7 @@ function getEventData() { function getInputs() { info("Getting inputs for run."); const inputs = { + forceAll: getRunInputString("forceAll", false), fileSets: getRunInputString("fileSets", false), triggers: getRunInputString("triggers", true), repositoryRoot: getRunInputString("repositoryRoot", true) @@ -33712,6 +33713,11 @@ function getInvokeContext() { return { token, owner, repo, event }; } var runInputsConfiguration = { + forceAll: { + parameter: "force-all", + localParameter: "FORCE_ALL", + validator: (v) => v === "true" || v === "false" + }, fileSets: { parameter: "file-sets", localParameter: "FILE_SETS" @@ -47555,12 +47561,18 @@ var triggerConfigSchema = external_exports.object({ */ alwaysTriggerOn: external_exports.array(external_exports.string()) }); +var ALLOWED_TRIGGER_KEYS = /* @__PURE__ */ new Set([ + "inclusion-sets", + "exclusion-sets", + "paths", + "always-trigger-on" +]); var triggerRawSchema = external_exports.object({ "inclusion-sets": external_exports.array(external_exports.string()).optional(), "exclusion-sets": external_exports.array(external_exports.string()).optional(), paths: external_exports.array(external_exports.string()).optional(), "always-trigger-on": external_exports.array(external_exports.string()).optional() -}).strict(); +}).passthrough(); function buildTriggersSchema(fileSets) { return external_exports.record(external_exports.string(), triggerRawSchema).superRefine((triggers, ctx) => { if (Object.keys(triggers).length === 0) { @@ -47571,6 +47583,15 @@ function buildTriggersSchema(fileSets) { return; } for (const [name, config2] of Object.entries(triggers)) { + for (const key of Object.keys(config2)) { + if (!ALLOWED_TRIGGER_KEYS.has(key)) { + ctx.addIssue({ + code: "custom", + path: [name, key], + message: `Unknown key "${key}". Allowed keys: "inclusion-sets", "exclusion-sets", "paths", "always-trigger-on".` + }); + } + } validateTrigger(name, config2, fileSets, ctx); } }); @@ -47581,7 +47602,7 @@ function validateTrigger(name, config2, fileSets, ctx) { ctx.addIssue({ code: "custom", path: [name, "inclusion-sets", i], - message: `Unknown file-set "${setName}".` + message: `unknown file-set "${setName}" in "inclusion-sets".` }); } } @@ -47590,7 +47611,7 @@ function validateTrigger(name, config2, fileSets, ctx) { ctx.addIssue({ code: "custom", path: [name, "exclusion-sets", i], - message: `Unknown file-set "${setName}".` + message: `unknown file-set "${setName}" in "exclusion-sets".` }); } } @@ -47832,6 +47853,23 @@ async function run() { `Parsed ${triggers.length} trigger(s): ${triggers.map((t) => t.name).join(", ")}` ); endGroup(); + if (inputs.forceAll === "true") { + startGroup("force-all override"); + info( + "force-all is true \u2014 skipping file matching, all triggers set to matched." + ); + const triggerResults2 = triggers.map((t) => ({ + name: t.name, + matched: true, + candidateCount: 0, + matchedFiles: [] + })); + endGroup(); + startGroup("Setting outputs"); + setOutputs({ triggerResults: triggerResults2 }); + endGroup(); + return; + } startGroup("Determining changed files"); info(`Event type: ${context3.event.eventName}`); let changedFiles = null; diff --git a/actions/advanced-triggers/src/__tests__/filtering.test.ts b/actions/advanced-triggers/src/__tests__/filtering.test.ts new file mode 100644 index 000000000..779673772 --- /dev/null +++ b/actions/advanced-triggers/src/__tests__/filtering.test.ts @@ -0,0 +1,444 @@ +import { vi, describe, test, expect } from "vitest"; + +vi.mock("@actions/core", () => ({ + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +})); + +import { parseTriggers } from "../schema"; +import { applyTrigger, type TriggerConfig } from "../filters"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a TriggerConfig directly for targeted applyTrigger unit tests. */ +function trigger( + overrides: Partial & { name?: string } = {}, +): TriggerConfig { + return { + name: "test", + positivePatterns: ["**/*.go"], + negatedPatterns: [], + alwaysTriggerOn: ["schedule", "workflow_dispatch"], + ...overrides, + }; +} + +/** + * Parse a triggers YAML against a file-sets map and return the first trigger. + * Used for integration-style tests that verify the full parse→apply pipeline. + */ +function parseFirst( + triggersYaml: string, + fileSets: Record = {}, +): TriggerConfig { + return parseTriggers(triggersYaml, fileSets)[0]; +} + +// --------------------------------------------------------------------------- +// applyTrigger — unit tests (directly constructed TriggerConfig) +// --------------------------------------------------------------------------- + +describe("applyTrigger — basic matching", () => { + test("matches when a changed file satisfies a positive pattern", () => { + const result = applyTrigger( + ["core/foo.go", "docs/readme.md"], + trigger({ positivePatterns: ["**/*.go"] }), + ); + expect(result.matched).toBe(true); + }); + + test("does not match when no changed file satisfies any positive pattern", () => { + const result = applyTrigger( + ["docs/readme.md", "package.json"], + trigger({ positivePatterns: ["**/*.go"] }), + ); + expect(result.matched).toBe(false); + }); + + test("does not match when changed files list is empty", () => { + expect(applyTrigger([], trigger()).matched).toBe(false); + }); + + test("only one file needs to match — not all", () => { + const result = applyTrigger( + ["docs/readme.md", "core/foo.go", "package.json"], + trigger({ positivePatterns: ["**/*.go"] }), + ); + expect(result.matched).toBe(true); + }); + + test("any positive pattern matching is sufficient", () => { + const result = applyTrigger( + ["docs/readme.md"], + trigger({ positivePatterns: ["**/*.go", "docs/**"] }), + ); + expect(result.matched).toBe(true); + }); + + test("exact file path matches", () => { + const result = applyTrigger( + ["tools/bin/go_core_tests"], + trigger({ positivePatterns: ["tools/bin/go_core_tests"] }), + ); + expect(result.matched).toBe(true); + }); +}); + +describe("applyTrigger — exclusion pass", () => { + test("excluded files are removed before positive matching", () => { + const result = applyTrigger( + ["vendor/foo.go"], + trigger({ + positivePatterns: ["**/*.go"], + negatedPatterns: ["**/vendor/**"], + }), + ); + expect(result.matched).toBe(false); + }); + + test("non-excluded files still participate in positive matching", () => { + const result = applyTrigger( + ["vendor/foo.go", "core/bar.go"], + trigger({ + positivePatterns: ["**/*.go"], + negatedPatterns: ["**/vendor/**"], + }), + ); + expect(result.matched).toBe(true); + }); + + test("all files excluded results in no match even with broad positive patterns", () => { + const result = applyTrigger( + ["system-tests/a.go", "system-tests/b.go"], + trigger({ + positivePatterns: ["**/*.go"], + negatedPatterns: ["system-tests/**"], + }), + ); + expect(result.matched).toBe(false); + }); + + test("multiple negated patterns each contribute to exclusion", () => { + const result = applyTrigger( + [ + "vendor/a.go", + "system-tests/b.go", + "integration-tests/c.go", + "core/d.go", + ], + trigger({ + positivePatterns: ["**/*.go"], + negatedPatterns: [ + "**/vendor/**", + "system-tests/**", + "integration-tests/**", + ], + }), + ); + // Only core/d.go survives exclusion + expect(result.matched).toBe(true); + expect(result.matchedFiles).toEqual(["core/d.go"]); + }); + + test("empty negatedPatterns means no files are excluded", () => { + const result = applyTrigger( + ["vendor/foo.go"], + trigger({ positivePatterns: ["**/*.go"], negatedPatterns: [] }), + ); + expect(result.matched).toBe(true); + }); +}); + +describe("applyTrigger — result shape", () => { + test("result carries the trigger name", () => { + const result = applyTrigger([], trigger({ name: "my-trigger" })); + expect(result.name).toBe("my-trigger"); + }); + + test("matchedFiles contains only the files that matched positive patterns", () => { + const result = applyTrigger( + ["core/foo.go", "docs/readme.md", "core/bar.go"], + trigger({ positivePatterns: ["**/*.go"] }), + ); + expect(result.matchedFiles).toEqual(["core/foo.go", "core/bar.go"]); + }); + + test("matchedFiles is empty when nothing matched", () => { + const result = applyTrigger( + ["docs/readme.md"], + trigger({ positivePatterns: ["**/*.go"] }), + ); + expect(result.matchedFiles).toEqual([]); + }); + + test("candidateCount reflects files remaining after exclusion", () => { + const result = applyTrigger( + ["vendor/a.go", "core/b.go", "core/c.go"], + trigger({ + positivePatterns: ["**/*.go"], + negatedPatterns: ["**/vendor/**"], + }), + ); + expect(result.candidateCount).toBe(2); + }); + + test("candidateCount equals total files when no negated patterns", () => { + const result = applyTrigger( + ["a.go", "b.go", "c.go"], + trigger({ positivePatterns: ["**/*.go"], negatedPatterns: [] }), + ); + expect(result.candidateCount).toBe(3); + }); +}); + +describe("applyTrigger — glob semantics", () => { + test("dotfiles are matched (dot: true)", () => { + const result = applyTrigger( + [".github/workflows/ci.yml"], + trigger({ positivePatterns: [".github/**"] }), + ); + expect(result.matched).toBe(true); + }); + + test("dotfiles in subdirectories are matched", () => { + const result = applyTrigger( + ["src/.hidden/foo.ts"], + trigger({ positivePatterns: ["**/*.ts"] }), + ); + expect(result.matched).toBe(true); + }); + + test("single * does not match across path separators", () => { + const result = applyTrigger( + ["core/sub/foo.go"], + trigger({ positivePatterns: ["core/*.go"] }), + ); + expect(result.matched).toBe(false); + }); + + test("** matches across path separators", () => { + const result = applyTrigger( + ["core/sub/deep/foo.go"], + trigger({ positivePatterns: ["core/**/*.go"] }), + ); + expect(result.matched).toBe(true); + }); + + test("empty positive patterns never match", () => { + // Normally prevented by parseTriggers, but applyTrigger must be safe regardless. + const result = applyTrigger( + ["core/foo.go"], + trigger({ positivePatterns: [], negatedPatterns: [] }), + ); + expect(result.matched).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — parse → apply pipeline with complex configs +// --------------------------------------------------------------------------- + +describe("integration: multiple inclusion sets", () => { + const fileSets = { + "go-files": ["**/*.go", "**/go.mod", "**/go.sum"], + "core-files": ["core/**"], + "workflow-files": [".github/workflows/**", ".github/actions/**"], + }; + + const triggersYaml = ` +core-tests: + inclusion-sets: [go-files, core-files, workflow-files] + paths: + - "tools/bin/go_core_tests" +`; + + test("matches a .go file anywhere", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["pkg/utils/helper.go"], t).matched).toBe(true); + }); + + test("matches a go.mod change", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["go.mod"], t).matched).toBe(true); + }); + + test("matches a file under core/", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["core/services/ocr2/ocr.go"], t).matched).toBe(true); + }); + + test("matches a workflow file change", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger([".github/workflows/ci-core.yml"], t).matched).toBe( + true, + ); + }); + + test("matches the exact inline path", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["tools/bin/go_core_tests"], t).matched).toBe(true); + }); + + test("does not match an unrelated file", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["docs/readme.md", "package.json"], t).matched).toBe( + false, + ); + }); +}); + +describe("integration: multiple exclusion sets", () => { + const fileSets = { + "go-files": ["**/*.go"], + vendor: ["**/vendor/**"], + "e2e-tests": ["system-tests/**", "integration-tests/**"], + }; + + const triggersYaml = ` +core-tests: + inclusion-sets: [go-files] + exclusion-sets: [vendor, e2e-tests] +`; + + test("matches a regular .go file", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["core/foo.go"], t).matched).toBe(true); + }); + + test("does not match a vendor .go file", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["**/vendor/foo.go"], t).matched).toBe(false); + }); + + test("does not match a system-tests .go file", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["system-tests/load/test.go"], t).matched).toBe(false); + }); + + test("does not match an integration-tests .go file", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["integration-tests/smoke/test.go"], t).matched).toBe( + false, + ); + }); + + test("matches when excluded and non-excluded files change together", () => { + const t = parseFirst(triggersYaml, fileSets); + const result = applyTrigger( + ["system-tests/a.go", "vendor/b.go", "core/c.go"], + t, + ); + expect(result.matched).toBe(true); + expect(result.matchedFiles).toEqual(["core/c.go"]); + }); + + test("does not match when only excluded files changed", () => { + const t = parseFirst(triggersYaml, fileSets); + const result = applyTrigger( + ["system-tests/a.go", "integration-tests/b.go", "vendor/c.go"], + t, + ); + expect(result.matched).toBe(false); + }); +}); + +describe("integration: inclusion sets + exclusion sets + mixed inline paths", () => { + // Simulates a real-world trigger: + // inclusion-sets: go-files, core-files + // exclusion-sets: e2e-tests + // paths: + // - "tools/bin/runner" ← extra positive + // - "!.github/workflows/readme-*.yml" ← one-off exclusion + const fileSets = { + "go-files": ["**/*.go", "**/go.mod"], + "core-files": ["core/**"], + "e2e-tests": ["system-tests/**", "integration-tests/**"], + }; + + const triggersYaml = ` +my-trigger: + inclusion-sets: [go-files, core-files] + exclusion-sets: [e2e-tests] + paths: + - "tools/bin/runner" + - "!.github/workflows/readme-*.yml" +`; + + test("positive patterns include all inclusion-set patterns plus inline path", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(t.positivePatterns).toContain("**/*.go"); + expect(t.positivePatterns).toContain("**/go.mod"); + expect(t.positivePatterns).toContain("core/**"); + expect(t.positivePatterns).toContain("tools/bin/runner"); + }); + + test("negated patterns include all exclusion-set patterns plus inline ! path", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(t.negatedPatterns).toContain("system-tests/**"); + expect(t.negatedPatterns).toContain("integration-tests/**"); + expect(t.negatedPatterns).toContain(".github/workflows/readme-*.yml"); + }); + + test("matches a core .go file", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["core/services/foo.go"], t).matched).toBe(true); + }); + + test("matches the inline exact path", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["tools/bin/runner"], t).matched).toBe(true); + }); + + test("does not match a file in e2e-tests (exclusion-set)", () => { + const t = parseFirst(triggersYaml, fileSets); + expect(applyTrigger(["system-tests/foo.go"], t).matched).toBe(false); + }); + + test("does not match readme workflow files (inline ! exclusion)", () => { + const t = parseFirst(triggersYaml, fileSets); + // This file would match core/** broadly if it weren't excluded + expect( + applyTrigger([".github/workflows/readme-integration.yml"], t).matched, + ).toBe(false); + }); + + test("matches a non-readme workflow file that would otherwise be excluded", () => { + // .github/workflows/ci.yml is NOT excluded by the readme-*.yml pattern + const t = parseFirst(triggersYaml, fileSets); + // Not in positive patterns directly — no match unless core/** covers it + // This test confirms the inline exclusion is scoped to readme-*.yml only + const result = applyTrigger( + [".github/workflows/readme-core.yml", "core/foo.go"], + t, + ); + // readme-core.yml is excluded; core/foo.go matches core/** + expect(result.matched).toBe(true); + expect(result.matchedFiles).toEqual(["core/foo.go"]); + }); + + test("no match when all changed files are excluded", () => { + const t = parseFirst(triggersYaml, fileSets); + expect( + applyTrigger(["system-tests/a.go", "integration-tests/b.go"], t).matched, + ).toBe(false); + }); + + test("candidateCount correctly reflects files surviving both exclusion sources", () => { + const t = parseFirst(triggersYaml, fileSets); + const result = applyTrigger( + [ + "system-tests/a.go", // excluded by exclusion-set + ".github/workflows/readme-b.yml", // excluded by inline ! path + "core/c.go", // survives + "core/d.go", // survives + ], + t, + ); + expect(result.candidateCount).toBe(2); + expect(result.matched).toBe(true); + }); +}); diff --git a/actions/advanced-triggers/src/__tests__/index.test.ts b/actions/advanced-triggers/src/__tests__/index.test.ts deleted file mode 100644 index 49e53eaeb..000000000 --- a/actions/advanced-triggers/src/__tests__/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, test, expect } from "vitest"; - -describe("placeholder test", () => { - test("placeholder", () => { - expect(true).toBe(true); - }); -}); diff --git a/actions/advanced-triggers/src/__tests__/parsing.test.ts b/actions/advanced-triggers/src/__tests__/parsing.test.ts new file mode 100644 index 000000000..4b9fbabdf --- /dev/null +++ b/actions/advanced-triggers/src/__tests__/parsing.test.ts @@ -0,0 +1,581 @@ +import { vi, describe, test, expect } from "vitest"; + +vi.mock("@actions/core", () => ({ + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +})); + +import { parseFileSets, parseTriggers } from "../schema"; + +// --------------------------------------------------------------------------- +// parseFileSets +// --------------------------------------------------------------------------- + +describe("parseFileSets", () => { + describe("empty / blank input", () => { + test("empty string returns empty map", () => { + expect(parseFileSets("")).toEqual({}); + }); + + test("whitespace-only string returns empty map", () => { + expect(parseFileSets(" \n ")).toEqual({}); + }); + }); + + describe("valid input", () => { + test("parses a single file-set", () => { + expect( + parseFileSets(` +go-files: + - "**/*.go" + - "**/go.mod" +`), + ).toEqual({ "go-files": ["**/*.go", "**/go.mod"] }); + }); + + test("parses multiple file-sets", () => { + expect( + parseFileSets(` +go-files: + - "**/*.go" +vendor: + - "**/vendor/**" +`), + ).toEqual({ + "go-files": ["**/*.go"], + vendor: ["**/vendor/**"], + }); + }); + + test("filters out blank pattern lines", () => { + const result = parseFileSets(` +go-files: + - "**/*.go" + - "" + - " " + - "**/go.mod" +`); + expect(result["go-files"]).toEqual(["**/*.go", "**/go.mod"]); + }); + + test("trims whitespace from patterns", () => { + const result = parseFileSets(` +go-files: + - " **/*.go " +`); + expect(result["go-files"]).toEqual(["**/*.go"]); + }); + }); + + describe("negation is forbidden in file-set definitions", () => { + test("throws when a pattern is negated", () => { + expect(() => + parseFileSets(` +vendor: + - "!**/vendor/**" +`), + ).toThrow(`Pattern must not be negated`); + }); + + test("error message tells the user to use exclusion-sets instead", () => { + // File-sets define what files ARE in a set — negation belongs at the + // trigger level via exclusion-sets, not inside the set definition. + expect(() => + parseFileSets(` +vendor: + - "!**/vendor/**" +`), + ).toThrow("exclusion-sets"); + }); + + test("throws even when mixed with valid positive patterns", () => { + expect(() => + parseFileSets(` +mixed: + - "**/*.go" + - "!**/vendor/**" +`), + ).toThrow("must not be negated"); + }); + }); + + describe("invalid input", () => { + test("throws on invalid YAML", () => { + expect(() => parseFileSets("{ bad: yaml: here")).toThrow( + "Failed to parse file-sets YAML", + ); + }); + + test("throws when top-level is an array", () => { + expect(() => parseFileSets("- foo\n- bar")).toThrow( + "file-sets input must be a YAML mapping", + ); + }); + + test("throws when top-level is a scalar string", () => { + expect(() => parseFileSets('"just a string"')).toThrow( + "file-sets input must be a YAML mapping", + ); + }); + + test("throws when a file-set value is not an array", () => { + expect(() => + parseFileSets(` +go-files: "**/*.go" +`), + ).toThrow(`[go-files]`); + }); + + test("throws when a pattern entry is not a string", () => { + expect(() => + parseFileSets(` +go-files: + - 42 +`), + ).toThrow(`[go-files → 0]`); + }); + }); +}); + +// --------------------------------------------------------------------------- +// parseTriggers +// --------------------------------------------------------------------------- + +describe("parseTriggers", () => { + describe("invalid YAML / top-level structure", () => { + test("throws on invalid YAML", () => { + expect(() => parseTriggers("{ bad: yaml: here")).toThrow( + "Failed to parse triggers YAML", + ); + }); + + test("throws when top-level is an array", () => { + expect(() => parseTriggers("- foo")).toThrow( + "triggers input must be a YAML mapping", + ); + }); + + test("throws when top-level is a scalar", () => { + expect(() => parseTriggers('"just a string"')).toThrow( + "triggers input must be a YAML mapping", + ); + }); + + test("throws when no triggers are defined", () => { + expect(() => parseTriggers("{}", {})).toThrow( + "No triggers defined in the triggers input", + ); + }); + }); + + describe("invalid trigger config", () => { + test("throws when trigger config is not a mapping", () => { + expect(() => + parseTriggers(` +my-trigger: "not a mapping" +`), + ).toThrow(`[my-trigger]`); + }); + + test("throws when trigger config is an array", () => { + expect(() => + parseTriggers(` +my-trigger: + - "foo" +`), + ).toThrow(`[my-trigger]`); + }); + + test("throws on unknown key in trigger config", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + unknown-key: foo +`), + ).toThrow(`Unknown key "unknown-key"`); + }); + + test("unknown key error message lists all allowed keys", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + filters: [foo] +`), + ).toThrow("inclusion-sets"); + }); + }); + + describe("pattern resolution", () => { + test("inline paths become positive patterns", () => { + const [t] = parseTriggers(` +my-trigger: + paths: + - "**/*.go" + - "docs/**" +`); + expect(t.positivePatterns).toEqual(["**/*.go", "docs/**"]); + expect(t.negatedPatterns).toEqual([]); + }); + + test("inline paths prefixed with ! become negated patterns", () => { + const [t] = parseTriggers(` +my-trigger: + paths: + - "**/*.go" + - "!**/vendor/**" +`); + expect(t.positivePatterns).toEqual(["**/*.go"]); + expect(t.negatedPatterns).toEqual(["**/vendor/**"]); + }); + + test("inclusion-sets patterns become positive patterns", () => { + const fileSets = { "go-files": ["**/*.go", "**/go.mod"] }; + const [t] = parseTriggers( + ` +my-trigger: + inclusion-sets: [go-files] +`, + fileSets, + ); + expect(t.positivePatterns).toEqual(["**/*.go", "**/go.mod"]); + expect(t.negatedPatterns).toEqual([]); + }); + + test("exclusion-sets patterns become negated patterns", () => { + const fileSets = { + "go-files": ["**/*.go"], + vendor: ["**/vendor/**"], + }; + const [t] = parseTriggers( + ` +my-trigger: + inclusion-sets: [go-files] + exclusion-sets: [vendor] +`, + fileSets, + ); + expect(t.positivePatterns).toEqual(["**/*.go"]); + expect(t.negatedPatterns).toEqual(["**/vendor/**"]); + }); + + test("multiple inclusion-sets are merged in declaration order", () => { + const fileSets = { + "go-files": ["**/*.go"], + "ts-files": ["**/*.ts"], + "workflow-files": [".github/workflows/**"], + }; + const [t] = parseTriggers( + ` +my-trigger: + inclusion-sets: [go-files, ts-files, workflow-files] +`, + fileSets, + ); + expect(t.positivePatterns).toEqual([ + "**/*.go", + "**/*.ts", + ".github/workflows/**", + ]); + }); + + test("multiple exclusion-sets are merged in declaration order", () => { + const fileSets = { + "go-files": ["**/*.go"], + vendor: ["**/vendor/**"], + "e2e-tests": ["system-tests/**", "integration-tests/**"], + }; + const [t] = parseTriggers( + ` +my-trigger: + inclusion-sets: [go-files] + exclusion-sets: [vendor, e2e-tests] +`, + fileSets, + ); + expect(t.negatedPatterns).toEqual([ + "**/vendor/**", + "system-tests/**", + "integration-tests/**", + ]); + }); + + test("all three sources combine: inclusion-sets, exclusion-sets, and paths", () => { + const fileSets = { + "go-files": ["**/*.go"], + vendor: ["**/vendor/**"], + }; + const [t] = parseTriggers( + ` +my-trigger: + inclusion-sets: [go-files] + exclusion-sets: [vendor] + paths: + - "tools/bin/runner" + - "!docs/**" +`, + fileSets, + ); + expect(t.positivePatterns).toEqual(["**/*.go", "tools/bin/runner"]); + expect(t.negatedPatterns).toEqual(["**/vendor/**", "docs/**"]); + }); + + test("blank and whitespace paths are ignored", () => { + const [t] = parseTriggers(` +my-trigger: + paths: + - "**/*.go" + - "" + - " " +`); + expect(t.positivePatterns).toEqual(["**/*.go"]); + }); + }); + + describe("always-trigger-on", () => { + test("defaults to [schedule, workflow_dispatch] when not specified", () => { + const [t] = parseTriggers(` +my-trigger: + paths: + - "**/*.go" +`); + expect(t.alwaysTriggerOn).toEqual(["schedule", "workflow_dispatch"]); + }); + + test("can be overridden with custom event list", () => { + const [t] = parseTriggers(` +my-trigger: + paths: + - "**/*.go" + always-trigger-on: + - schedule + - workflow_call +`); + expect(t.alwaysTriggerOn).toEqual(["schedule", "workflow_call"]); + }); + + test("can be set to empty list when trigger has positive patterns", () => { + const [t] = parseTriggers(` +my-trigger: + paths: + - "**/*.go" + always-trigger-on: [] +`); + expect(t.alwaysTriggerOn).toEqual([]); + }); + + test("trigger with no file patterns but non-empty always-trigger-on is valid", () => { + const [t] = parseTriggers(` +nightly-only: + always-trigger-on: + - schedule + - workflow_dispatch +`); + expect(t.positivePatterns).toEqual([]); + expect(t.negatedPatterns).toEqual([]); + expect(t.alwaysTriggerOn).toEqual(["schedule", "workflow_dispatch"]); + }); + + test("trigger with empty config body uses default always-trigger-on", () => { + const [t] = parseTriggers(` +my-trigger: {} +`); + expect(t.alwaysTriggerOn).toEqual(["schedule", "workflow_dispatch"]); + }); + }); + + describe("validation errors", () => { + test("throws when inclusion-sets references unknown file-set", () => { + expect(() => + parseTriggers(` +my-trigger: + inclusion-sets: [does-not-exist] +`), + ).toThrow(`unknown file-set "does-not-exist" in "inclusion-sets"`); + }); + + test("throws when exclusion-sets references unknown file-set", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + exclusion-sets: [does-not-exist] +`), + ).toThrow(`unknown file-set "does-not-exist" in "exclusion-sets"`); + }); + + test("throws when inclusion-sets is not an array", () => { + expect(() => + parseTriggers(` +my-trigger: + inclusion-sets: go-files +`), + ).toThrow(`[my-trigger → inclusion-sets]`); + }); + + test("throws when exclusion-sets is not an array", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + exclusion-sets: vendor +`), + ).toThrow(`[my-trigger → exclusion-sets]`); + }); + + test("throws when paths is not an array", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: "**/*.go" +`), + ).toThrow(`[my-trigger → paths]`); + }); + + test("throws when always-trigger-on is not an array", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + always-trigger-on: schedule +`), + ).toThrow(`[my-trigger → always-trigger-on]`); + }); + + test("throws when inclusion-sets entry is not a string", () => { + expect(() => + parseTriggers(` +my-trigger: + inclusion-sets: + - 42 +`), + ).toThrow(`[my-trigger → inclusion-sets → 0]`); + }); + + test("throws when exclusion-sets entry is not a string", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + exclusion-sets: + - 42 +`), + ).toThrow(`[my-trigger → exclusion-sets → 0]`); + }); + + test("throws when paths entry is not a string", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - 99 +`), + ).toThrow(`[my-trigger → paths → 0]`); + }); + + test("throws when always-trigger-on entry is not a string", () => { + expect(() => + parseTriggers(` +my-trigger: + paths: + - "**/*.go" + always-trigger-on: + - 123 +`), + ).toThrow(`[my-trigger → always-trigger-on → 0]`); + }); + + test("throws when only exclusion-sets are provided — no positive patterns", () => { + const fileSets = { vendor: ["**/vendor/**"] }; + expect(() => + parseTriggers( + ` +my-trigger: + exclusion-sets: [vendor] +`, + fileSets, + ), + ).toThrow("only negated patterns"); + }); + + test("throws when no patterns and always-trigger-on is explicitly empty", () => { + expect(() => + parseTriggers(` +my-trigger: + always-trigger-on: [] +`), + ).toThrow("can never output true"); + }); + }); + + describe("multiple triggers", () => { + test("parses multiple triggers preserving declaration order", () => { + const triggers = parseTriggers(` +alpha: + paths: + - "alpha/**" +beta: + paths: + - "beta/**" +gamma: + paths: + - "gamma/**" +`); + expect(triggers.map((t) => t.name)).toEqual(["alpha", "beta", "gamma"]); + }); + + test("each trigger resolves its own file-sets independently", () => { + const fileSets = { + "go-files": ["**/*.go"], + "ts-files": ["**/*.ts"], + }; + const [a, b] = parseTriggers( + ` +trigger-a: + inclusion-sets: [go-files] +trigger-b: + inclusion-sets: [ts-files] +`, + fileSets, + ); + expect(a.positivePatterns).toEqual(["**/*.go"]); + expect(b.positivePatterns).toEqual(["**/*.ts"]); + }); + + test("triggers can reference overlapping file-sets without interfering", () => { + const fileSets = { + "go-files": ["**/*.go"], + vendor: ["**/vendor/**"], + "e2e-tests": ["system-tests/**"], + }; + const [core, deployment] = parseTriggers( + ` +core-tests: + inclusion-sets: [go-files] + exclusion-sets: [vendor, e2e-tests] +deployment-tests: + inclusion-sets: [go-files] + exclusion-sets: [vendor] + paths: + - "deployment/**" +`, + fileSets, + ); + + // core-tests excludes both vendor and e2e-tests + expect(core.negatedPatterns).toEqual(["**/vendor/**", "system-tests/**"]); + // deployment-tests only excludes vendor, and adds deployment/** as positive + expect(deployment.negatedPatterns).toEqual(["**/vendor/**"]); + expect(deployment.positivePatterns).toContain("deployment/**"); + }); + }); +}); diff --git a/actions/advanced-triggers/src/run-inputs.ts b/actions/advanced-triggers/src/run-inputs.ts index 7e2bb7d29..57da0a75a 100644 --- a/actions/advanced-triggers/src/run-inputs.ts +++ b/actions/advanced-triggers/src/run-inputs.ts @@ -4,6 +4,7 @@ import * as github from "@actions/github"; import { getEventData } from "./event"; export interface RunInputs { + forceAll: string; fileSets: string; triggers: string; repositoryRoot: string; @@ -15,6 +16,7 @@ export function getInputs(): RunInputs { core.info("Getting inputs for run."); const inputs: RunInputs = { + forceAll: getRunInputString("forceAll", false), fileSets: getRunInputString("fileSets", false), triggers: getRunInputString("triggers", true), repositoryRoot: getRunInputString("repositoryRoot", true), @@ -81,6 +83,11 @@ interface RunInputConfiguration { const runInputsConfiguration: { [K in keyof RunInputs]: RunInputConfiguration; } = { + forceAll: { + parameter: "force-all", + localParameter: "FORCE_ALL", + validator: (v) => v === "true" || v === "false", + }, fileSets: { parameter: "file-sets", localParameter: "FILE_SETS", diff --git a/actions/advanced-triggers/src/run.ts b/actions/advanced-triggers/src/run.ts index 267fb243c..72db5d1f8 100644 --- a/actions/advanced-triggers/src/run.ts +++ b/actions/advanced-triggers/src/run.ts @@ -69,7 +69,27 @@ export async function run(): Promise { ); core.endGroup(); - // 3. Determine changed files for file-change events. + // 3. Short-circuit if force-all is set. + if (inputs.forceAll === "true") { + core.startGroup("force-all override"); + core.info( + "force-all is true — skipping file matching, all triggers set to matched.", + ); + const triggerResults = triggers.map((t) => ({ + name: t.name, + matched: true, + candidateCount: 0, + matchedFiles: [], + })); + core.endGroup(); + core.startGroup("Setting outputs"); + setOutputs({ triggerResults }); + core.endGroup(); + return; + } + + // 4. Determine changed files for file-change events. + core.startGroup("Determining changed files"); core.info(`Event type: ${context.event.eventName}`); let changedFiles: string[] | null = null; @@ -91,7 +111,7 @@ export async function run(): Promise { } core.endGroup(); - // 4. Apply each trigger. + // 5. Apply each trigger. core.startGroup("Applying triggers"); const triggerResults: TriggerResult[] = []; for (const trigger of triggers) { @@ -127,7 +147,7 @@ export async function run(): Promise { } core.endGroup(); - // 5. Set outputs. + // 6. Set outputs. core.startGroup("Setting outputs"); setOutputs({ triggerResults }); core.endGroup(); diff --git a/actions/advanced-triggers/src/schema.ts b/actions/advanced-triggers/src/schema.ts index 17e25ff9e..47c458701 100644 --- a/actions/advanced-triggers/src/schema.ts +++ b/actions/advanced-triggers/src/schema.ts @@ -70,6 +70,15 @@ export type TriggerConfig = z.infer; // Triggers (raw input) schema // --------------------------------------------------------------------------- +const ALLOWED_TRIGGER_KEYS = new Set([ + "inclusion-sets", + "exclusion-sets", + "paths", + "always-trigger-on", +]); + +// Uses passthrough so unknown keys flow through to the outer superRefine, +// where they are reported with full context (trigger name + allowed key list). const triggerRawSchema = z .object({ "inclusion-sets": z.array(z.string()).optional(), @@ -77,9 +86,15 @@ const triggerRawSchema = z paths: z.array(z.string()).optional(), "always-trigger-on": z.array(z.string()).optional(), }) - .strict(); + .passthrough(); -type TriggerRaw = z.infer; +// Explicit type for use after validation — excludes the passthrough index signature. +type TriggerRaw = { + "inclusion-sets"?: string[]; + "exclusion-sets"?: string[]; + paths?: string[]; + "always-trigger-on"?: string[]; +}; function buildTriggersSchema(fileSets: FileSets) { return z.record(z.string(), triggerRawSchema).superRefine((triggers, ctx) => { @@ -92,7 +107,17 @@ function buildTriggersSchema(fileSets: FileSets) { } for (const [name, config] of Object.entries(triggers)) { - validateTrigger(name, config, fileSets, ctx); + // Check for unknown keys with a helpful message listing allowed keys. + for (const key of Object.keys(config)) { + if (!ALLOWED_TRIGGER_KEYS.has(key)) { + ctx.addIssue({ + code: "custom", + path: [name, key], + message: `Unknown key "${key}". Allowed keys: "inclusion-sets", "exclusion-sets", "paths", "always-trigger-on".`, + }); + } + } + validateTrigger(name, config as TriggerRaw, fileSets, ctx); } }); } @@ -109,7 +134,7 @@ function validateTrigger( ctx.addIssue({ code: "custom", path: [name, "inclusion-sets", i], - message: `Unknown file-set "${setName}".`, + message: `unknown file-set "${setName}" in "inclusion-sets".`, }); } } @@ -120,7 +145,7 @@ function validateTrigger( ctx.addIssue({ code: "custom", path: [name, "exclusion-sets", i], - message: `Unknown file-set "${setName}".`, + message: `unknown file-set "${setName}" in "exclusion-sets".`, }); } }