From eaed3e5923b6465f561695ece0691ce9ab8f27f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:39:59 +0000 Subject: [PATCH 1/7] Initial plan From 2a75f26bbc454b42c85800b7aae24d992b1efa15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:06:40 +0000 Subject: [PATCH 2/7] feat: add skip-if-check-failed gate for pre-activation CI check verification Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/071f27c2-e891-4ee7-838c-dc7171ebe068 --- .../setup/js/check_skip_if_check_failed.cjs | 133 +++++++++ .../js/check_skip_if_check_failed.test.cjs | 237 +++++++++++++++ pkg/constants/constants.go | 2 + pkg/parser/schemas/main_workflow_schema.json | 35 +++ .../compiler_orchestrator_workflow.go | 5 + pkg/workflow/compiler_pre_activation_job.go | 34 +++ pkg/workflow/compiler_types.go | 114 ++++---- pkg/workflow/frontmatter_extraction_yaml.go | 30 ++ pkg/workflow/skip_if_check_failed_test.go | 276 ++++++++++++++++++ pkg/workflow/stop_after.go | 105 ++++++- 10 files changed, 916 insertions(+), 55 deletions(-) create mode 100644 actions/setup/js/check_skip_if_check_failed.cjs create mode 100644 actions/setup/js/check_skip_if_check_failed.test.cjs create mode 100644 pkg/workflow/skip_if_check_failed_test.go diff --git a/actions/setup/js/check_skip_if_check_failed.cjs b/actions/setup/js/check_skip_if_check_failed.cjs new file mode 100644 index 0000000000..7701e3cb47 --- /dev/null +++ b/actions/setup/js/check_skip_if_check_failed.cjs @@ -0,0 +1,133 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_API } = require("./error_codes.cjs"); + +/** + * Determines the ref to check for CI status. + * Uses GH_AW_SKIP_BRANCH if set, otherwise falls back to the PR base branch + * (for pull_request events) or the current ref. + * + * @returns {string} The ref to use for the check run query + */ +function resolveRef() { + const explicitBranch = process.env.GH_AW_SKIP_BRANCH; + if (explicitBranch) { + return explicitBranch; + } + + // For pull_request events, default to the base (target) branch + const payload = context.payload; + if (payload && payload.pull_request && payload.pull_request.base && payload.pull_request.base.ref) { + return payload.pull_request.base.ref; + } + + // Fall back to the triggering ref, stripping the "refs/heads/" prefix if present + const ref = context.ref; + if (ref && ref.startsWith("refs/heads/")) { + return ref.slice("refs/heads/".length); + } + return ref; +} + +/** + * Parses a JSON list from an environment variable. + * + * @param {string | undefined} envValue + * @returns {string[] | null} + */ +function parseListEnv(envValue) { + if (!envValue) { + return null; + } + try { + const parsed = JSON.parse(envValue); + if (!Array.isArray(parsed)) { + return null; + } + return parsed.filter(item => typeof item === "string"); + } catch { + return null; + } +} + +async function main() { + const includeEnv = process.env.GH_AW_SKIP_CHECK_INCLUDE; + const excludeEnv = process.env.GH_AW_SKIP_CHECK_EXCLUDE; + + const includeList = parseListEnv(includeEnv); + const excludeList = parseListEnv(excludeEnv); + + const ref = resolveRef(); + if (!ref) { + core.setFailed("skip-if-check-failed: could not determine the ref to check."); + return; + } + + const { owner, repo } = context.repo; + core.info(`Checking CI checks on ref: ${ref} (${owner}/${repo})`); + + if (includeList && includeList.length > 0) { + core.info(`Including only checks: ${includeList.join(", ")}`); + } + if (excludeList && excludeList.length > 0) { + core.info(`Excluding checks: ${excludeList.join(", ")}`); + } + + try { + // Fetch all check runs for the ref (paginate to handle repos with many checks) + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref, + per_page: 100, + }); + + core.info(`Found ${checkRuns.length} check run(s) on ref "${ref}"`); + + // Filter to the latest run per check name (GitHub may have multiple runs per name) + /** @type {Map} */ + const latestByName = new Map(); + for (const run of checkRuns) { + const name = run.name; + const existing = latestByName.get(name); + if (!existing || new Date(run.started_at ?? 0) > new Date(existing.started_at ?? 0)) { + latestByName.set(name, run); + } + } + + // Apply include/exclude filtering + const relevant = []; + for (const [name, run] of latestByName) { + if (includeList && includeList.length > 0 && !includeList.includes(name)) { + continue; + } + if (excludeList && excludeList.length > 0 && excludeList.includes(name)) { + continue; + } + relevant.push(run); + } + + core.info(`Evaluating ${relevant.length} check run(s) after filtering`); + + // A check is considered "failed" if it has completed with a non-success conclusion + const failedConclusions = new Set(["failure", "cancelled", "timed_out"]); + + const failingChecks = relevant.filter(run => run.status === "completed" && run.conclusion != null && failedConclusions.has(run.conclusion)); + + if (failingChecks.length > 0) { + const names = failingChecks.map(r => `${r.name} (${r.conclusion})`).join(", "); + core.warning(`⚠️ Failing CI checks detected on "${ref}": ${names}. Workflow execution will be prevented by activation job.`); + core.setOutput("skip_if_check_failed_ok", "false"); + return; + } + + core.info(`✓ No failing checks found on "${ref}", workflow can proceed`); + core.setOutput("skip_if_check_failed_ok", "true"); + } catch (error) { + core.setFailed(`${ERR_API}: Failed to fetch check runs for ref "${ref}": ${getErrorMessage(error)}`); + } +} + +module.exports = { main }; diff --git a/actions/setup/js/check_skip_if_check_failed.test.cjs b/actions/setup/js/check_skip_if_check_failed.test.cjs new file mode 100644 index 0000000000..22b97608ab --- /dev/null +++ b/actions/setup/js/check_skip_if_check_failed.test.cjs @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +describe("check_skip_if_check_failed.cjs", () => { + let mockCore; + let mockGithub; + let mockContext; + + beforeEach(() => { + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + }; + + mockGithub = { + rest: { + checks: { + listForRef: vi.fn(), + }, + }, + paginate: vi.fn(), + }; + + mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, + ref: "refs/heads/main", + payload: {}, + }; + + global.core = mockCore; + global.github = mockGithub; + global.context = mockContext; + + vi.resetModules(); + }); + + afterEach(() => { + vi.clearAllMocks(); + delete global.core; + delete global.github; + delete global.context; + delete process.env.GH_AW_SKIP_BRANCH; + delete process.env.GH_AW_SKIP_CHECK_INCLUDE; + delete process.env.GH_AW_SKIP_CHECK_EXCLUDE; + }); + + it("should allow workflow when all checks pass", async () => { + mockGithub.paginate.mockResolvedValue([ + { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, + { name: "test", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should cancel workflow when a check has failed", async () => { + mockGithub.paginate.mockResolvedValue([ + { name: "build", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + { name: "test", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failing CI checks detected")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build")); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should cancel workflow when a check was cancelled", async () => { + mockGithub.paginate.mockResolvedValue([{ name: "ci", status: "completed", conclusion: "cancelled", started_at: "2024-01-01T00:00:00Z" }]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + }); + + it("should cancel workflow when a check timed out", async () => { + mockGithub.paginate.mockResolvedValue([{ name: "ci", status: "completed", conclusion: "timed_out", started_at: "2024-01-01T00:00:00Z" }]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + }); + + it("should allow workflow when checks are still in progress", async () => { + mockGithub.paginate.mockResolvedValue([{ name: "build", status: "in_progress", conclusion: null, started_at: "2024-01-01T00:00:00Z" }]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should allow workflow when no checks exist", async () => { + mockGithub.paginate.mockResolvedValue([]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should use the PR base branch when triggered by pull_request event", async () => { + mockContext.payload = { + pull_request: { + base: { ref: "main" }, + }, + }; + mockContext.ref = "refs/pull/42/merge"; + + mockGithub.paginate.mockResolvedValue([]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "main" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should use GH_AW_SKIP_BRANCH when explicitly configured", async () => { + process.env.GH_AW_SKIP_BRANCH = "release/v2"; + mockGithub.paginate.mockResolvedValue([]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "release/v2" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should strip refs/heads/ prefix when resolving branch from ref", async () => { + mockContext.payload = {}; + mockContext.ref = "refs/heads/feature-branch"; + + mockGithub.paginate.mockResolvedValue([]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "feature-branch" })); + }); + + it("should only check included checks when GH_AW_SKIP_CHECK_INCLUDE is set", async () => { + process.env.GH_AW_SKIP_CHECK_INCLUDE = JSON.stringify(["build", "test"]); + mockGithub.paginate.mockResolvedValue([ + { name: "build", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + { name: "lint", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // build is in include list and failed → should cancel + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build")); + // lint is not in include list → should NOT appear in warning + const warningCalls = mockCore.warning.mock.calls.flat().join(" "); + expect(warningCalls).not.toContain("lint"); + }); + + it("should allow workflow when failing check is not in include list", async () => { + process.env.GH_AW_SKIP_CHECK_INCLUDE = JSON.stringify(["build"]); + mockGithub.paginate.mockResolvedValue([ + { name: "lint", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // lint is not in include list, build passed → allow + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should ignore excluded checks when GH_AW_SKIP_CHECK_EXCLUDE is set", async () => { + process.env.GH_AW_SKIP_CHECK_EXCLUDE = JSON.stringify(["lint"]); + mockGithub.paginate.mockResolvedValue([ + { name: "lint", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // lint is excluded, build passed → allow + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should cancel when non-excluded check fails", async () => { + process.env.GH_AW_SKIP_CHECK_EXCLUDE = JSON.stringify(["lint"]); + mockGithub.paginate.mockResolvedValue([ + { name: "lint", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + { name: "build", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // build not excluded and failed → cancel + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + }); + + it("should use the latest run for each check name", async () => { + // Two runs for the same check name, the newer one passes + mockGithub.paginate.mockResolvedValue([ + { name: "build", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-02T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // Latest run passed → allow + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + }); + + it("should fail with error message when API call fails", async () => { + mockGithub.paginate.mockRejectedValue(new Error("Rate limit exceeded")); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to fetch check runs")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Rate limit exceeded")); + expect(mockCore.setOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 8cc15fdb37..be48399500 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -718,6 +718,7 @@ const GetTriggerLabelStepID StepID = "get_trigger_label" const CheckRateLimitStepID StepID = "check_rate_limit" const CheckSkipRolesStepID StepID = "check_skip_roles" const CheckSkipBotsStepID StepID = "check_skip_bots" +const CheckSkipIfCheckFailedStepID StepID = "check_skip_if_check_failed" // PreActivationAppTokenStepID is the step ID for the unified GitHub App token mint step // emitted in the pre-activation job when on.github-app is configured alongside skip-if checks. @@ -733,6 +734,7 @@ const MatchedCommandOutput = "matched_command" const RateLimitOkOutput = "rate_limit_ok" const SkipRolesOkOutput = "skip_roles_ok" const SkipBotsOkOutput = "skip_bots_ok" +const SkipIfCheckFailedOkOutput = "skip_if_check_failed_ok" const ActivatedOutput = "activated" // Rate limit defaults diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index fc1dff4be0..95139466f2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1471,6 +1471,41 @@ ], "description": "Conditionally skip workflow execution when a GitHub search query has no matches (or fewer than minimum). Can be a string (query only, implies min=1) or an object with 'query', optional 'min', and 'scope' fields. Use top-level on.github-token or on.github-app for custom authentication." }, + "skip-if-check-failed": { + "oneOf": [ + { + "type": "boolean", + "enum": [true], + "description": "Skip workflow execution if any CI checks on the target branch are currently failing. For pull_request events, checks the base branch. For other events, checks the current ref." + }, + { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of check names to evaluate. When specified, only these named checks are considered. If omitted, all checks are evaluated." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of check names to ignore. Checks in this list are not considered when determining whether to skip the workflow." + }, + "branch": { + "type": "string", + "description": "Branch name to check for failing CI checks. When omitted, defaults to the base branch of a pull_request event or the current ref for other events." + } + }, + "additionalProperties": false, + "description": "Skip-if-check-failed configuration object with optional include/exclude filter lists and an optional branch name." + } + ], + "description": "Skip workflow execution if any CI checks on the target branch are failing. Accepts true (check all) or an object to filter specific checks by name and optionally specify a branch." + }, "skip-roles": { "oneOf": [ { diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index a03b25bee3..5a82b0e7fd 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -836,6 +836,11 @@ func (c *Compiler) processOnSectionAndFilters( return err } + // Process skip-if-check-failed configuration from the on: section + if err := c.processSkipIfCheckFailedConfiguration(frontmatter, workflowData); err != nil { + return err + } + // Process manual-approval configuration from the on: section if err := c.processManualApprovalConfiguration(frontmatter, workflowData); err != nil { return err diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index b226daaefa..bd2fc0994b 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -151,6 +151,30 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_no_match.cjs")) } + // Add skip-if-check-failed check if configured + if data.SkipIfCheckFailed != nil { + steps = append(steps, " - name: Check skip-if-check-failed\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfCheckFailedStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + if len(data.SkipIfCheckFailed.Include) > 0 || len(data.SkipIfCheckFailed.Exclude) > 0 || data.SkipIfCheckFailed.Branch != "" { + steps = append(steps, " env:\n") + if len(data.SkipIfCheckFailed.Include) > 0 { + includeJSON, _ := json.Marshal(data.SkipIfCheckFailed.Include) + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_CHECK_INCLUDE: %q\n", string(includeJSON))) + } + if len(data.SkipIfCheckFailed.Exclude) > 0 { + excludeJSON, _ := json.Marshal(data.SkipIfCheckFailed.Exclude) + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_CHECK_EXCLUDE: %q\n", string(excludeJSON))) + } + if data.SkipIfCheckFailed.Branch != "" { + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_BRANCH: %q\n", data.SkipIfCheckFailed.Branch)) + } + } + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_check_failed.cjs")) + } + // Add skip-roles check if configured if len(data.SkipRoles) > 0 { // Extract workflow name for the skip-roles check @@ -267,6 +291,16 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec conditions = append(conditions, skipNoMatchCheckOk) } + if data.SkipIfCheckFailed != nil { + // Add skip-if-check-failed check condition + skipIfCheckFailedOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfCheckFailedStepID, constants.SkipIfCheckFailedOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, skipIfCheckFailedOk) + } + if len(data.SkipRoles) > 0 { // Add skip-roles check condition skipRolesCheckOk := BuildComparison( diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 443545570a..600ac92387 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -343,7 +343,12 @@ type SkipIfNoMatchConfig struct { // Auth (github-token / github-app) is taken from on.github-token / on.github-app at the top level. } -// WorkflowData holds all the data needed to generate a GitHub Actions workflow +// SkipIfCheckFailedConfig holds the configuration for skip-if-check-failed conditions +type SkipIfCheckFailedConfig struct { + Include []string // check names to include (empty = all checks) + Exclude []string // check names to exclude + Branch string // optional branch name to check (defaults to triggering ref or PR base branch) +} type WorkflowData struct { Name string WorkflowID string // workflow identifier derived from markdown filename (basename without extension) @@ -383,59 +388,60 @@ type WorkflowData struct { AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging StopTime string - SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold - SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold - SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) - SkipBots []string // users to skip workflow for (e.g., [user1, user2]) - OnSteps []map[string]any // steps to inject into the pre-activation job from on.steps - OnPermissions *Permissions // additional permissions for the pre-activation job from on.permissions - ManualApproval string // environment name for manual approval from on: section - Command []string // for /command trigger support - multiple command names - CommandEvents []string // events where command should be active (nil = all events) - CommandOtherEvents map[string]any // for merging command with other events - LabelCommand []string // for label-command trigger support - label names that act as commands - LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion) - LabelCommandOtherEvents map[string]any // for merging label-command with other events - LabelCommandRemoveLabel bool // whether to automatically remove the triggering label (default: true) - AIReaction string // AI reaction type like "eyes", "heart", etc. - StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) - ActivationGitHubToken string // custom github token from on.github-token for reactions/comments - ActivationGitHubApp *GitHubAppConfig // github app config from on.github-app for minting activation tokens - TopLevelGitHubApp *GitHubAppConfig // top-level github-app fallback for all nested github-app token minting operations - LockForAgent bool // whether to lock the issue during agent workflow execution - Jobs map[string]any // custom job configurations with dependencies - Cache string // cache configuration - NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} - NetworkPermissions *NetworkPermissions // parsed network permissions - SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) - SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes - MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools - Roles []string // permission levels required to trigger workflow - Bots []string // allow list of bot identifiers that can trigger workflow - RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers - CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration - RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration - Runtimes map[string]any // runtime version overrides from frontmatter - APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install - ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) - ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) - Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) - ActionCache *ActionCache // cache for action pin resolutions - ActionResolver *ActionResolver // resolver for action pins - StrictMode bool // strict mode for action pinning - SecretMasking *SecretMaskingConfig // secret masking configuration - ParsedFrontmatter *FrontmatterConfig // cached parsed frontmatter configuration (for performance optimization) - RawFrontmatter map[string]any // raw parsed frontmatter map (for passing to hash functions without re-parsing) - ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") - ActionMode ActionMode // action mode for workflow compilation (dev, release, script) - HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter - InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) - CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter - CheckoutDisabled bool // true when checkout: false is set in frontmatter - HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand) - ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) - IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) - EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps + SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold + SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold + SkipIfCheckFailed *SkipIfCheckFailedConfig // skip-if-check-failed configuration + SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) + SkipBots []string // users to skip workflow for (e.g., [user1, user2]) + OnSteps []map[string]any // steps to inject into the pre-activation job from on.steps + OnPermissions *Permissions // additional permissions for the pre-activation job from on.permissions + ManualApproval string // environment name for manual approval from on: section + Command []string // for /command trigger support - multiple command names + CommandEvents []string // events where command should be active (nil = all events) + CommandOtherEvents map[string]any // for merging command with other events + LabelCommand []string // for label-command trigger support - label names that act as commands + LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion) + LabelCommandOtherEvents map[string]any // for merging label-command with other events + LabelCommandRemoveLabel bool // whether to automatically remove the triggering label (default: true) + AIReaction string // AI reaction type like "eyes", "heart", etc. + StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) + ActivationGitHubToken string // custom github token from on.github-token for reactions/comments + ActivationGitHubApp *GitHubAppConfig // github app config from on.github-app for minting activation tokens + TopLevelGitHubApp *GitHubAppConfig // top-level github-app fallback for all nested github-app token minting operations + LockForAgent bool // whether to lock the issue during agent workflow execution + Jobs map[string]any // custom job configurations with dependencies + Cache string // cache configuration + NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} + NetworkPermissions *NetworkPermissions // parsed network permissions + SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) + SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes + MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools + Roles []string // permission levels required to trigger workflow + Bots []string // allow list of bot identifiers that can trigger workflow + RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers + CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration + RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration + Runtimes map[string]any // runtime version overrides from frontmatter + APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install + ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) + ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) + Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) + ActionCache *ActionCache // cache for action pin resolutions + ActionResolver *ActionResolver // resolver for action pins + StrictMode bool // strict mode for action pinning + SecretMasking *SecretMaskingConfig // secret masking configuration + ParsedFrontmatter *FrontmatterConfig // cached parsed frontmatter configuration (for performance optimization) + RawFrontmatter map[string]any // raw parsed frontmatter map (for passing to hash functions without re-parsing) + ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") + ActionMode ActionMode // action mode for workflow compilation (dev, release, script) + HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter + InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) + CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter + CheckoutDisabled bool // true when checkout: false is set in frontmatter + HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand) + ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) + IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) + EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps } // BaseSafeOutputConfig holds common configuration fields for all safe output types diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index 186a9650d4..8bddab72fc 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -134,6 +134,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat inForksArray := false inSkipIfMatch := false inSkipIfNoMatch := false + inSkipIfCheckFailed := false inSkipRolesArray := false inSkipBotsArray := false inRolesArray := false @@ -269,6 +270,15 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } } + // Check if we're entering skip-if-check-failed object + if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfCheckFailed { + // Check both uncommented and commented forms + if trimmedLine == "skip-if-check-failed:" || + (strings.HasPrefix(trimmedLine, "# skip-if-check-failed:") && strings.Contains(trimmedLine, "pre-activation job")) { + inSkipIfCheckFailed = true + } + } + // Check if we're entering github-app object if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inGitHubApp { // Check both uncommented and commented forms @@ -304,6 +314,19 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } } + // Check if we're leaving skip-if-check-failed object (encountering another top-level field) + // Skip this check if we just entered skip-if-check-failed on this line + if inSkipIfCheckFailed && strings.TrimSpace(line) != "" && + !strings.HasPrefix(trimmedLine, "skip-if-check-failed:") && + !strings.HasPrefix(trimmedLine, "# skip-if-check-failed:") { + // Get the indentation of the current line + lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) + // If this is a field at same level as skip-if-check-failed (2 spaces) and not a comment, we're out + if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { + inSkipIfCheckFailed = false + } + } + // Check if we're leaving github-app object (encountering another top-level field) // Skip this check if we just entered github-app on this line if inGitHubApp && strings.TrimSpace(line) != "" && @@ -417,6 +440,13 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat // Comment out nested fields in skip-if-no-match object shouldComment = true commentReason = "" + } else if strings.HasPrefix(trimmedLine, "skip-if-check-failed:") { + shouldComment = true + commentReason = " # Skip-if-check-failed processed as check status gate in pre-activation job" + } else if inSkipIfCheckFailed && (strings.HasPrefix(trimmedLine, "include:") || strings.HasPrefix(trimmedLine, "exclude:") || strings.HasPrefix(trimmedLine, "branch:") || strings.HasPrefix(trimmedLine, "-")) { + // Comment out nested fields and list items in skip-if-check-failed object + shouldComment = true + commentReason = "" } else if strings.HasPrefix(trimmedLine, "skip-roles:") { shouldComment = true commentReason = " # Skip-roles processed as role check in pre-activation job" diff --git a/pkg/workflow/skip_if_check_failed_test.go b/pkg/workflow/skip_if_check_failed_test.go new file mode 100644 index 0000000000..523a0b0f0c --- /dev/null +++ b/pkg/workflow/skip_if_check_failed_test.go @@ -0,0 +1,276 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + + "github.com/github/gh-aw/pkg/testutil" +) + +// TestSkipIfCheckFailedPreActivationJob tests that skip-if-check-failed check is created correctly in pre-activation job +func TestSkipIfCheckFailedPreActivationJob(t *testing.T) { + tmpDir := testutil.TempDir(t, "skip-if-check-failed-test") + + compiler := NewCompiler() + + t.Run("pre_activation_job_created_with_skip_if_check_failed_boolean", func(t *testing.T) { + workflowContent := `--- +on: + pull_request: + types: [opened, synchronize] + skip-if-check-failed: true +engine: claude +--- + +# Skip If Check Failed Workflow + +This workflow has a skip-if-check-failed configuration. +` + workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify pre_activation job exists + if !strings.Contains(lockContentStr, "pre_activation:") { + t.Error("Expected pre_activation job to be created") + } + + // Verify skip-if-check-failed check step is present + if !strings.Contains(lockContentStr, "Check skip-if-check-failed") { + t.Error("Expected skip-if-check-failed check step to be present") + } + + // Verify the step ID is set + if !strings.Contains(lockContentStr, "id: check_skip_if_check_failed") { + t.Error("Expected check_skip_if_check_failed step ID") + } + + // Verify the activated output includes the check condition + if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok") { + t.Error("Expected activated output to include skip_if_check_failed_ok condition") + } + + // Verify skip-if-check-failed is commented out in the frontmatter + if !strings.Contains(lockContentStr, "# skip-if-check-failed:") { + t.Error("Expected skip-if-check-failed to be commented out in lock file") + } + + if !strings.Contains(lockContentStr, "Skip-if-check-failed processed as check status gate in pre-activation job") { + t.Error("Expected comment explaining skip-if-check-failed processing") + } + }) + + t.Run("pre_activation_job_created_with_skip_if_check_failed_object_with_include_and_exclude", func(t *testing.T) { + workflowContent := `--- +on: + pull_request: + types: [opened, synchronize] + skip-if-check-failed: + include: + - build + - test + exclude: + - lint + branch: main +engine: claude +--- + +# Skip If Check Failed Object Form + +This workflow uses the object form of skip-if-check-failed. +` + workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-object-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify skip-if-check-failed check step is present + if !strings.Contains(lockContentStr, "Check skip-if-check-failed") { + t.Error("Expected skip-if-check-failed check step to be present") + } + + // Verify include list is passed as JSON env var + if !strings.Contains(lockContentStr, `GH_AW_SKIP_CHECK_INCLUDE: "[\"build\",\"test\"]"`) { + t.Error("Expected GH_AW_SKIP_CHECK_INCLUDE environment variable with correct value") + } + + // Verify exclude list is passed as JSON env var + if !strings.Contains(lockContentStr, `GH_AW_SKIP_CHECK_EXCLUDE: "[\"lint\"]"`) { + t.Error("Expected GH_AW_SKIP_CHECK_EXCLUDE environment variable with correct value") + } + + // Verify branch is passed + if !strings.Contains(lockContentStr, `GH_AW_SKIP_BRANCH: "main"`) { + t.Error("Expected GH_AW_SKIP_BRANCH environment variable with correct value") + } + + // Verify condition is in activated output + if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok") { + t.Error("Expected activated output to include skip_if_check_failed_ok condition") + } + }) + + t.Run("skip_if_check_failed_no_env_vars_when_bare_true", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/30 * * * *" + skip-if-check-failed: true +engine: claude +--- + +# Bare Skip If Check Failed + +Skips if any checks fail on the default branch. +` + workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-bare-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // When bare true, no env vars should be set (no include/exclude/branch) + if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_INCLUDE") { + t.Error("Expected no GH_AW_SKIP_CHECK_INCLUDE when using bare true form") + } + if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_EXCLUDE") { + t.Error("Expected no GH_AW_SKIP_CHECK_EXCLUDE when using bare true form") + } + if strings.Contains(lockContentStr, "GH_AW_SKIP_BRANCH") { + t.Error("Expected no GH_AW_SKIP_BRANCH when using bare true form") + } + }) + + t.Run("skip_if_check_failed_combined_with_other_gates", func(t *testing.T) { + workflowContent := `--- +on: + pull_request: + types: [opened, synchronize] + skip-if-match: "is:pr is:open label:blocked" + skip-if-check-failed: + include: + - build + roles: [admin, maintainer] +engine: claude +--- + +# Combined Gates + +This workflow combines multiple gate types. +` + workflowFile := filepath.Join(tmpDir, "combined-gates-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // All conditions should appear in the activated output + if !strings.Contains(lockContentStr, "steps.check_membership.outputs.is_team_member == 'true'") { + t.Error("Expected membership check condition in activated output") + } + if !strings.Contains(lockContentStr, "steps.check_skip_if_match.outputs.skip_check_ok == 'true'") { + t.Error("Expected skip_check_ok condition in activated output") + } + if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok == 'true'") { + t.Error("Expected skip_if_check_failed_ok condition in activated output") + } + }) + + t.Run("skip_if_check_failed_object_without_branch", func(t *testing.T) { + workflowContent := `--- +on: + pull_request: + types: [opened] + skip-if-check-failed: + exclude: + - spelling +engine: claude +--- + +# Skip with exclude only + +Skips if non-spelling checks fail. +` + workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-no-branch.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + if !strings.Contains(lockContentStr, `GH_AW_SKIP_CHECK_EXCLUDE: "[\"spelling\"]"`) { + t.Error("Expected GH_AW_SKIP_CHECK_EXCLUDE environment variable") + } + if strings.Contains(lockContentStr, "GH_AW_SKIP_BRANCH") { + t.Error("Expected no GH_AW_SKIP_BRANCH when branch not specified") + } + }) +} diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index 618dc106e0..47b4c879aa 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -404,7 +404,110 @@ func (c *Compiler) processSkipIfNoMatchConfiguration(frontmatter map[string]any, return nil } -// extractSkipIfScope extracts the optional scope field from a skip-if-match or skip-if-no-match +// extractSkipIfCheckFailedFromOn extracts the skip-if-check-failed value from the on: section +func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, workflowData ...*WorkflowData) (*SkipIfCheckFailedConfig, error) { + // Use cached On field from ParsedFrontmatter if available (when workflowData is provided) + var onSection any + var exists bool + if len(workflowData) > 0 && workflowData[0] != nil && workflowData[0].ParsedFrontmatter != nil && workflowData[0].ParsedFrontmatter.On != nil { + onSection = workflowData[0].ParsedFrontmatter.On + exists = true + } else { + onSection, exists = frontmatter["on"] + } + + if !exists { + return nil, nil + } + + // Handle different formats of the on: section + switch on := onSection.(type) { + case string: + // Simple string format like "on: push" - no skip-if-check-failed possible + return nil, nil + case map[string]any: + // Complex object format - look for skip-if-check-failed + if skipIfCheckFailed, exists := on["skip-if-check-failed"]; exists { + switch skip := skipIfCheckFailed.(type) { + case bool: + // Simple boolean format: skip-if-check-failed: true + if !skip { + return nil, errors.New("skip-if-check-failed: false is not valid; remove the field to disable the check") + } + return &SkipIfCheckFailedConfig{}, nil + case map[string]any: + // Object format: skip-if-check-failed: { include: [...], exclude: [...], branch: "..." } + config := &SkipIfCheckFailedConfig{} + + // Extract include list (optional) + if includeRaw, hasInclude := skip["include"]; hasInclude { + includeSlice, ok := includeRaw.([]any) + if !ok { + return nil, errors.New("skip-if-check-failed 'include' field must be a list of strings. Example:\n skip-if-check-failed:\n include:\n - build\n - test") + } + for _, item := range includeSlice { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("skip-if-check-failed 'include' list items must be strings, got %T", item) + } + config.Include = append(config.Include, s) + } + } + + // Extract exclude list (optional) + if excludeRaw, hasExclude := skip["exclude"]; hasExclude { + excludeSlice, ok := excludeRaw.([]any) + if !ok { + return nil, errors.New("skip-if-check-failed 'exclude' field must be a list of strings. Example:\n skip-if-check-failed:\n exclude:\n - lint") + } + for _, item := range excludeSlice { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("skip-if-check-failed 'exclude' list items must be strings, got %T", item) + } + config.Exclude = append(config.Exclude, s) + } + } + + // Extract branch (optional) + if branchRaw, hasBranch := skip["branch"]; hasBranch { + branchStr, ok := branchRaw.(string) + if !ok { + return nil, fmt.Errorf("skip-if-check-failed 'branch' field must be a string, got %T. Example: branch: main", branchRaw) + } + config.Branch = branchStr + } + + return config, nil + default: + return nil, fmt.Errorf("skip-if-check-failed value must be true or an object, got %T. Examples:\n skip-if-check-failed: true\n skip-if-check-failed:\n include:\n - build\n branch: main", skipIfCheckFailed) + } + } + return nil, nil + default: + return nil, errors.New("invalid on: section format") + } +} + +// processSkipIfCheckFailedConfiguration extracts and processes skip-if-check-failed configuration from frontmatter +func (c *Compiler) processSkipIfCheckFailedConfiguration(frontmatter map[string]any, workflowData *WorkflowData) error { + skipIfCheckFailedConfig, err := c.extractSkipIfCheckFailedFromOn(frontmatter, workflowData) + if err != nil { + return err + } + workflowData.SkipIfCheckFailed = skipIfCheckFailedConfig + + if workflowData.SkipIfCheckFailed != nil { + stopAfterLog.Printf("Skip-if-check-failed configured: include=%v, exclude=%v, branch=%q", + workflowData.SkipIfCheckFailed.Include, + workflowData.SkipIfCheckFailed.Exclude, + workflowData.SkipIfCheckFailed.Branch, + ) + } + + return nil +} + // object configuration. Auth fields (github-token, github-app) are configured at the top-level // on: section and are no longer accepted inside skip-if blocks. // conditionName is used only for error messages (e.g. "skip-if-match"). From 5d75a24f7052aa0695a0063e28de2cc0bc9592ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:14:57 +0000 Subject: [PATCH 3/7] fix: ignore deployment gate checks (github-deployments app) in skip-if-check-failed Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/23230180-da68-481a-afd7-3ddf2b696db5 --- .../setup/js/check_skip_if_check_failed.cjs | 32 ++++++++++++-- .../js/check_skip_if_check_failed.test.cjs | 44 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/check_skip_if_check_failed.cjs b/actions/setup/js/check_skip_if_check_failed.cjs index 7701e3cb47..3248818668 100644 --- a/actions/setup/js/check_skip_if_check_failed.cjs +++ b/actions/setup/js/check_skip_if_check_failed.cjs @@ -52,6 +52,22 @@ function parseListEnv(envValue) { } } +/** + * Returns true for check runs that represent deployment environment gates rather + * than CI checks. These should be ignored by default so that a pending deployment + * approval does not falsely block the agentic workflow. + * + * Deployment gate checks are identified by the GitHub App that created them: + * - "github-deployments" – the built-in GitHub Deployments service + * + * @param {object} run - A check run object from the GitHub API + * @returns {boolean} + */ +function isDeploymentCheck(run) { + const slug = run.app?.slug; + return slug === "github-deployments"; +} + async function main() { const includeEnv = process.env.GH_AW_SKIP_CHECK_INCLUDE; const excludeEnv = process.env.GH_AW_SKIP_CHECK_EXCLUDE; @@ -86,10 +102,16 @@ async function main() { core.info(`Found ${checkRuns.length} check run(s) on ref "${ref}"`); - // Filter to the latest run per check name (GitHub may have multiple runs per name) - /** @type {Map} */ + // Filter to the latest run per check name (GitHub may have multiple runs per name). + // Deployment gate checks are silently skipped here so they never influence the gate. + /** @type {Map} */ const latestByName = new Map(); + let deploymentCheckCount = 0; for (const run of checkRuns) { + if (isDeploymentCheck(run)) { + deploymentCheckCount++; + continue; + } const name = run.name; const existing = latestByName.get(name); if (!existing || new Date(run.started_at ?? 0) > new Date(existing.started_at ?? 0)) { @@ -97,7 +119,11 @@ async function main() { } } - // Apply include/exclude filtering + if (deploymentCheckCount > 0) { + core.info(`Skipping ${deploymentCheckCount} deployment gate check(s) (app: github-deployments)`); + } + + // Apply user-defined include/exclude filtering const relevant = []; for (const [name, run] of latestByName) { if (includeList && includeList.length > 0 && !includeList.includes(name)) { diff --git a/actions/setup/js/check_skip_if_check_failed.test.cjs b/actions/setup/js/check_skip_if_check_failed.test.cjs index 22b97608ab..e511ac09e7 100644 --- a/actions/setup/js/check_skip_if_check_failed.test.cjs +++ b/actions/setup/js/check_skip_if_check_failed.test.cjs @@ -234,4 +234,48 @@ describe("check_skip_if_check_failed.cjs", () => { expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Rate limit exceeded")); expect(mockCore.setOutput).not.toHaveBeenCalled(); }); + + it("should ignore deployment gate checks from github-deployments app", async () => { + mockGithub.paginate.mockResolvedValue([ + // Regular CI check that passes + { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-actions" } }, + // Deployment gate (waiting for approval) — should be ignored even if it shows as failing + { name: "production", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // Deployment gate is ignored, build passed → allow + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping 1 deployment gate check(s)")); + }); + + it("should allow workflow when only deployment checks are failing", async () => { + mockGithub.paginate.mockResolvedValue([ + { name: "staging", status: "completed", conclusion: "cancelled", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, + { name: "production", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // All checks are deployment gates → no CI checks to evaluate → allow + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping 2 deployment gate check(s)")); + }); + + it("should still cancel when a non-deployment check fails alongside a deployment gate", async () => { + mockGithub.paginate.mockResolvedValue([ + { name: "build", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-actions" } }, + { name: "production", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, + ]); + + const { main } = await import("./check_skip_if_check_failed.cjs"); + await main(); + + // build failed (not a deployment gate) → cancel + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build")); + }); }); From aa1b91f19a815a269df87ab8dffdefd7f74c0c55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:32:36 +0000 Subject: [PATCH 4/7] feat: support null value for skip-if-check-failed and add gate to issue-monster Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/93f39d18-a319-4330-8cc6-8c186615c85c --- .github/workflows/issue-monster.lock.yml | 14 +++++- .github/workflows/issue-monster.md | 1 + pkg/parser/schemas/main_workflow_schema.json | 4 ++ pkg/workflow/skip_if_check_failed_test.go | 51 ++++++++++++++++++++ pkg/workflow/stop_after.go | 5 +- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 03403bf78d..e4490d6f2a 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -26,7 +26,7 @@ # Imports: # - shared/activation-app.md # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"810d295f3ec4eadebaf8a208dc5c623ac79ed44e3a0ac78f15876712044f5328","strict":true,"agent_id":"copilot","agent_model":"gpt-5.1-codex-mini"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"14a42ae61df626b64b8c5391217ca864b39ae4c44095803408d55588a495203c","strict":true,"agent_id":"copilot","agent_model":"gpt-5.1-codex-mini"} name: "Issue Monster" "on": @@ -36,6 +36,7 @@ name: "Issue Monster" schedule: - cron: "*/30 * * * *" # Friendly format: every 30m + # skip-if-check-failed: # Skip-if-check-failed processed as check status gate in pre-activation job # skip-if-match: # Skip-if-match processed as search check in pre-activation job # max: 5 # query: is:pr is:open is:draft author:app/copilot-swe-agent @@ -1363,7 +1364,7 @@ jobs: issues: read pull-requests: read outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_if_match.outputs.skip_check_ok == 'true' && steps.check_skip_if_no_match.outputs.skip_no_match_check_ok == 'true' }} + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_if_match.outputs.skip_check_ok == 'true' && steps.check_skip_if_no_match.outputs.skip_no_match_check_ok == 'true' && steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok == 'true' }} has_issues: ${{ steps.search.outputs.has_issues }} issue_count: ${{ steps.search.outputs.issue_count }} issue_list: ${{ steps.search.outputs.issue_list }} @@ -1420,6 +1421,15 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_if_no_match.cjs'); await main(); + - name: Check skip-if-check-failed + id: check_skip_if_check_failed + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_if_check_failed.cjs'); + await main(); - name: Search for candidate issues id: search uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 diff --git a/.github/workflows/issue-monster.md b/.github/workflows/issue-monster.md index 9a7e531a70..f6237779d6 100644 --- a/.github/workflows/issue-monster.md +++ b/.github/workflows/issue-monster.md @@ -8,6 +8,7 @@ on: query: "is:pr is:open is:draft author:app/copilot-swe-agent" max: 5 skip-if-no-match: "is:issue is:open" + skip-if-check-failed: permissions: issues: read pull-requests: read diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 95139466f2..1c1124e441 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1473,6 +1473,10 @@ }, "skip-if-check-failed": { "oneOf": [ + { + "type": "null", + "description": "Bare key with no value — equivalent to true. Skips workflow execution if any CI checks on the target branch are currently failing." + }, { "type": "boolean", "enum": [true], diff --git a/pkg/workflow/skip_if_check_failed_test.go b/pkg/workflow/skip_if_check_failed_test.go index 523a0b0f0c..bf94d64eee 100644 --- a/pkg/workflow/skip_if_check_failed_test.go +++ b/pkg/workflow/skip_if_check_failed_test.go @@ -273,4 +273,55 @@ Skips if non-spelling checks fail. t.Error("Expected no GH_AW_SKIP_BRANCH when branch not specified") } }) + + t.Run("skip_if_check_failed_null_value_treated_as_true", func(t *testing.T) { + // skip-if-check-failed: (no value / YAML null) should behave identically to skip-if-check-failed: true + workflowContent := `--- +on: + pull_request: + types: [opened, synchronize] + skip-if-check-failed: +engine: claude +--- + +# Skip If Check Failed Null Value + +This workflow uses the bare null form of skip-if-check-failed. +` + workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-null-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Should produce the check step, just like skip-if-check-failed: true + if !strings.Contains(lockContentStr, "Check skip-if-check-failed") { + t.Error("Expected skip-if-check-failed check step to be present") + } + if !strings.Contains(lockContentStr, "id: check_skip_if_check_failed") { + t.Error("Expected check_skip_if_check_failed step ID") + } + // No env vars since no include/exclude/branch + if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_INCLUDE") { + t.Error("Expected no GH_AW_SKIP_CHECK_INCLUDE for bare null form") + } + if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_EXCLUDE") { + t.Error("Expected no GH_AW_SKIP_CHECK_EXCLUDE for bare null form") + } + if strings.Contains(lockContentStr, "GH_AW_SKIP_BRANCH") { + t.Error("Expected no GH_AW_SKIP_BRANCH for bare null form") + } + }) } diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index 47b4c879aa..483b955833 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -429,6 +429,9 @@ func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, wo // Complex object format - look for skip-if-check-failed if skipIfCheckFailed, exists := on["skip-if-check-failed"]; exists { switch skip := skipIfCheckFailed.(type) { + case nil: + // Null value (bare key with no value): skip-if-check-failed: + return &SkipIfCheckFailedConfig{}, nil case bool: // Simple boolean format: skip-if-check-failed: true if !skip { @@ -480,7 +483,7 @@ func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, wo return config, nil default: - return nil, fmt.Errorf("skip-if-check-failed value must be true or an object, got %T. Examples:\n skip-if-check-failed: true\n skip-if-check-failed:\n include:\n - build\n branch: main", skipIfCheckFailed) + return nil, fmt.Errorf("skip-if-check-failed value must be true or an object, got %T. Examples:\n skip-if-check-failed:\n skip-if-check-failed: true\n skip-if-check-failed:\n include:\n - build\n branch: main", skipIfCheckFailed) } } return nil, nil From efcddc33d01d99e048319d1190848e07a700d745 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:53:41 +0000 Subject: [PATCH 5/7] feat: rename skip-if-check-failed to skip-if-check-failing, use getBaseBranch helper, fix parseListEnv Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/06b924ed-c0f8-4611-aa63-ca118ac96f26 --- .github/workflows/issue-monster.lock.yml | 12 +- .github/workflows/issue-monster.md | 2 +- ...ed.cjs => check_skip_if_check_failing.cjs} | 43 ++++--- ...s => check_skip_if_check_failing.test.cjs} | 78 ++++++------ pkg/constants/constants.go | 4 +- pkg/parser/schemas/main_workflow_schema.json | 4 +- .../compiler_orchestrator_workflow.go | 4 +- pkg/workflow/compiler_pre_activation_job.go | 34 +++--- pkg/workflow/compiler_types.go | 112 +++++++++--------- pkg/workflow/frontmatter_extraction_yaml.go | 34 +++--- ..._test.go => skip_if_check_failing_test.go} | 102 ++++++++-------- pkg/workflow/stop_after.go | 56 ++++----- 12 files changed, 243 insertions(+), 242 deletions(-) rename actions/setup/js/{check_skip_if_check_failed.cjs => check_skip_if_check_failing.cjs} (81%) rename actions/setup/js/{check_skip_if_check_failed.test.cjs => check_skip_if_check_failing.test.cjs} (82%) rename pkg/workflow/{skip_if_check_failed_test.go => skip_if_check_failing_test.go} (71%) diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index e4490d6f2a..3c55d947a8 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -26,7 +26,7 @@ # Imports: # - shared/activation-app.md # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"14a42ae61df626b64b8c5391217ca864b39ae4c44095803408d55588a495203c","strict":true,"agent_id":"copilot","agent_model":"gpt-5.1-codex-mini"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"05a9cf1fdb06143030aaf12d29c1c7e2d5dfb4a7557e227e44c4be97cd73e332","strict":true,"agent_id":"copilot","agent_model":"gpt-5.1-codex-mini"} name: "Issue Monster" "on": @@ -36,7 +36,7 @@ name: "Issue Monster" schedule: - cron: "*/30 * * * *" # Friendly format: every 30m - # skip-if-check-failed: # Skip-if-check-failed processed as check status gate in pre-activation job + # skip-if-check-failing: # Skip-if-check-failing processed as check status gate in pre-activation job # skip-if-match: # Skip-if-match processed as search check in pre-activation job # max: 5 # query: is:pr is:open is:draft author:app/copilot-swe-agent @@ -1364,7 +1364,7 @@ jobs: issues: read pull-requests: read outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_if_match.outputs.skip_check_ok == 'true' && steps.check_skip_if_no_match.outputs.skip_no_match_check_ok == 'true' && steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok == 'true' }} + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_if_match.outputs.skip_check_ok == 'true' && steps.check_skip_if_no_match.outputs.skip_no_match_check_ok == 'true' && steps.check_skip_if_check_failing.outputs.skip_if_check_failing_ok == 'true' }} has_issues: ${{ steps.search.outputs.has_issues }} issue_count: ${{ steps.search.outputs.issue_count }} issue_list: ${{ steps.search.outputs.issue_list }} @@ -1421,14 +1421,14 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_if_no_match.cjs'); await main(); - - name: Check skip-if-check-failed - id: check_skip_if_check_failed + - name: Check skip-if-check-failing + id: check_skip_if_check_failing uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_if_check_failed.cjs'); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_if_check_failing.cjs'); await main(); - name: Search for candidate issues id: search diff --git a/.github/workflows/issue-monster.md b/.github/workflows/issue-monster.md index f6237779d6..8f30c76687 100644 --- a/.github/workflows/issue-monster.md +++ b/.github/workflows/issue-monster.md @@ -8,7 +8,7 @@ on: query: "is:pr is:open is:draft author:app/copilot-swe-agent" max: 5 skip-if-no-match: "is:issue is:open" - skip-if-check-failed: + skip-if-check-failing: permissions: issues: read pull-requests: read diff --git a/actions/setup/js/check_skip_if_check_failed.cjs b/actions/setup/js/check_skip_if_check_failing.cjs similarity index 81% rename from actions/setup/js/check_skip_if_check_failed.cjs rename to actions/setup/js/check_skip_if_check_failing.cjs index 3248818668..51894b450a 100644 --- a/actions/setup/js/check_skip_if_check_failed.cjs +++ b/actions/setup/js/check_skip_if_check_failing.cjs @@ -3,32 +3,22 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API } = require("./error_codes.cjs"); +const { getBaseBranch } = require("./get_base_branch.cjs"); /** * Determines the ref to check for CI status. - * Uses GH_AW_SKIP_BRANCH if set, otherwise falls back to the PR base branch - * (for pull_request events) or the current ref. + * Uses GH_AW_SKIP_BRANCH if set as an explicit override, otherwise delegates + * to the shared getBaseBranch() helper which handles PR base branch, issue_comment + * on PR, and repository default branch resolution. * - * @returns {string} The ref to use for the check run query + * @returns {Promise} The ref to use for the check run query */ -function resolveRef() { +async function resolveRef() { const explicitBranch = process.env.GH_AW_SKIP_BRANCH; if (explicitBranch) { return explicitBranch; } - - // For pull_request events, default to the base (target) branch - const payload = context.payload; - if (payload && payload.pull_request && payload.pull_request.base && payload.pull_request.base.ref) { - return payload.pull_request.base.ref; - } - - // Fall back to the triggering ref, stripping the "refs/heads/" prefix if present - const ref = context.ref; - if (ref && ref.startsWith("refs/heads/")) { - return ref.slice("refs/heads/".length); - } - return ref; + return getBaseBranch(); } /** @@ -46,7 +36,16 @@ function parseListEnv(envValue) { if (!Array.isArray(parsed)) { return null; } - return parsed.filter(item => typeof item === "string"); + // Trim, filter out empty strings, and remove duplicates + const filtered = [ + ...new Set( + parsed + .filter(item => typeof item === "string") + .map(item => item.trim()) + .filter(item => item !== "") + ), + ]; + return filtered.length > 0 ? filtered : null; } catch { return null; } @@ -75,9 +74,9 @@ async function main() { const includeList = parseListEnv(includeEnv); const excludeList = parseListEnv(excludeEnv); - const ref = resolveRef(); + const ref = await resolveRef(); if (!ref) { - core.setFailed("skip-if-check-failed: could not determine the ref to check."); + core.setFailed("skip-if-check-failing: could not determine the ref to check."); return; } @@ -145,12 +144,12 @@ async function main() { if (failingChecks.length > 0) { const names = failingChecks.map(r => `${r.name} (${r.conclusion})`).join(", "); core.warning(`⚠️ Failing CI checks detected on "${ref}": ${names}. Workflow execution will be prevented by activation job.`); - core.setOutput("skip_if_check_failed_ok", "false"); + core.setOutput("skip_if_check_failing_ok", "false"); return; } core.info(`✓ No failing checks found on "${ref}", workflow can proceed`); - core.setOutput("skip_if_check_failed_ok", "true"); + core.setOutput("skip_if_check_failing_ok", "true"); } catch (error) { core.setFailed(`${ERR_API}: Failed to fetch check runs for ref "${ref}": ${getErrorMessage(error)}`); } diff --git a/actions/setup/js/check_skip_if_check_failed.test.cjs b/actions/setup/js/check_skip_if_check_failing.test.cjs similarity index 82% rename from actions/setup/js/check_skip_if_check_failed.test.cjs rename to actions/setup/js/check_skip_if_check_failing.test.cjs index e511ac09e7..7ba3237e1d 100644 --- a/actions/setup/js/check_skip_if_check_failed.test.cjs +++ b/actions/setup/js/check_skip_if_check_failing.test.cjs @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -describe("check_skip_if_check_failed.cjs", () => { +describe("check_skip_if_check_failing.cjs", () => { let mockCore; let mockGithub; let mockContext; @@ -44,6 +44,7 @@ describe("check_skip_if_check_failed.cjs", () => { delete process.env.GH_AW_SKIP_BRANCH; delete process.env.GH_AW_SKIP_CHECK_INCLUDE; delete process.env.GH_AW_SKIP_CHECK_EXCLUDE; + delete process.env.GITHUB_BASE_REF; }); it("should allow workflow when all checks pass", async () => { @@ -52,10 +53,10 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "test", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); @@ -65,49 +66,49 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "test", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failing CI checks detected")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build")); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); it("should cancel workflow when a check was cancelled", async () => { mockGithub.paginate.mockResolvedValue([{ name: "ci", status: "completed", conclusion: "cancelled", started_at: "2024-01-01T00:00:00Z" }]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); }); it("should cancel workflow when a check timed out", async () => { mockGithub.paginate.mockResolvedValue([{ name: "ci", status: "completed", conclusion: "timed_out", started_at: "2024-01-01T00:00:00Z" }]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); }); it("should allow workflow when checks are still in progress", async () => { mockGithub.paginate.mockResolvedValue([{ name: "build", status: "in_progress", conclusion: null, started_at: "2024-01-01T00:00:00Z" }]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should allow workflow when no checks exist", async () => { mockGithub.paginate.mockResolvedValue([]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should use the PR base branch when triggered by pull_request event", async () => { @@ -120,34 +121,35 @@ describe("check_skip_if_check_failed.cjs", () => { mockGithub.paginate.mockResolvedValue([]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "main" })); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should use GH_AW_SKIP_BRANCH when explicitly configured", async () => { process.env.GH_AW_SKIP_BRANCH = "release/v2"; mockGithub.paginate.mockResolvedValue([]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "release/v2" })); - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); - it("should strip refs/heads/ prefix when resolving branch from ref", async () => { + it("should use GITHUB_BASE_REF when set (standard pull_request event env)", async () => { + process.env.GITHUB_BASE_REF = "develop"; mockContext.payload = {}; - mockContext.ref = "refs/heads/feature-branch"; mockGithub.paginate.mockResolvedValue([]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); - expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "feature-branch" })); + expect(mockGithub.paginate).toHaveBeenCalledWith(mockGithub.rest.checks.listForRef, expect.objectContaining({ ref: "develop" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should only check included checks when GH_AW_SKIP_CHECK_INCLUDE is set", async () => { @@ -157,11 +159,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "lint", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // build is in include list and failed → should cancel - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build")); // lint is not in include list → should NOT appear in warning const warningCalls = mockCore.warning.mock.calls.flat().join(" "); @@ -175,11 +177,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // lint is not in include list, build passed → allow - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should ignore excluded checks when GH_AW_SKIP_CHECK_EXCLUDE is set", async () => { @@ -189,11 +191,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-01T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // lint is excluded, build passed → allow - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should cancel when non-excluded check fails", async () => { @@ -203,11 +205,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "build", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // build not excluded and failed → cancel - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); }); it("should use the latest run for each check name", async () => { @@ -217,17 +219,17 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "build", status: "completed", conclusion: "success", started_at: "2024-01-02T00:00:00Z" }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // Latest run passed → allow - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); }); it("should fail with error message when API call fails", async () => { mockGithub.paginate.mockRejectedValue(new Error("Rate limit exceeded")); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to fetch check runs")); @@ -243,11 +245,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "production", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // Deployment gate is ignored, build passed → allow - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping 1 deployment gate check(s)")); }); @@ -257,11 +259,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "production", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // All checks are deployment gates → no CI checks to evaluate → allow - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping 2 deployment gate check(s)")); }); @@ -271,11 +273,11 @@ describe("check_skip_if_check_failed.cjs", () => { { name: "production", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z", app: { slug: "github-deployments" } }, ]); - const { main } = await import("./check_skip_if_check_failed.cjs"); + const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); // build failed (not a deployment gate) → cancel - expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failed_ok", "false"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build")); }); }); diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index be48399500..07ba35b6d2 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -718,7 +718,7 @@ const GetTriggerLabelStepID StepID = "get_trigger_label" const CheckRateLimitStepID StepID = "check_rate_limit" const CheckSkipRolesStepID StepID = "check_skip_roles" const CheckSkipBotsStepID StepID = "check_skip_bots" -const CheckSkipIfCheckFailedStepID StepID = "check_skip_if_check_failed" +const CheckSkipIfCheckFailingStepID StepID = "check_skip_if_check_failing" // PreActivationAppTokenStepID is the step ID for the unified GitHub App token mint step // emitted in the pre-activation job when on.github-app is configured alongside skip-if checks. @@ -734,7 +734,7 @@ const MatchedCommandOutput = "matched_command" const RateLimitOkOutput = "rate_limit_ok" const SkipRolesOkOutput = "skip_roles_ok" const SkipBotsOkOutput = "skip_bots_ok" -const SkipIfCheckFailedOkOutput = "skip_if_check_failed_ok" +const SkipIfCheckFailingOkOutput = "skip_if_check_failing_ok" const ActivatedOutput = "activated" // Rate limit defaults diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 1c1124e441..7e4628a8d3 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1471,7 +1471,7 @@ ], "description": "Conditionally skip workflow execution when a GitHub search query has no matches (or fewer than minimum). Can be a string (query only, implies min=1) or an object with 'query', optional 'min', and 'scope' fields. Use top-level on.github-token or on.github-app for custom authentication." }, - "skip-if-check-failed": { + "skip-if-check-failing": { "oneOf": [ { "type": "null", @@ -1505,7 +1505,7 @@ } }, "additionalProperties": false, - "description": "Skip-if-check-failed configuration object with optional include/exclude filter lists and an optional branch name." + "description": "Skip-if-check-failing configuration object with optional include/exclude filter lists and an optional branch name." } ], "description": "Skip workflow execution if any CI checks on the target branch are failing. Accepts true (check all) or an object to filter specific checks by name and optionally specify a branch." diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 5a82b0e7fd..0f12b899cd 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -836,8 +836,8 @@ func (c *Compiler) processOnSectionAndFilters( return err } - // Process skip-if-check-failed configuration from the on: section - if err := c.processSkipIfCheckFailedConfiguration(frontmatter, workflowData); err != nil { + // Process skip-if-check-failing configuration from the on: section + if err := c.processSkipIfCheckFailingConfiguration(frontmatter, workflowData); err != nil { return err } diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index bd2fc0994b..9ceced8bda 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -151,28 +151,28 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_no_match.cjs")) } - // Add skip-if-check-failed check if configured - if data.SkipIfCheckFailed != nil { - steps = append(steps, " - name: Check skip-if-check-failed\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfCheckFailedStepID)) + // Add skip-if-check-failing check if configured + if data.SkipIfCheckFailing != nil { + steps = append(steps, " - name: Check skip-if-check-failing\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfCheckFailingStepID)) steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - if len(data.SkipIfCheckFailed.Include) > 0 || len(data.SkipIfCheckFailed.Exclude) > 0 || data.SkipIfCheckFailed.Branch != "" { + if len(data.SkipIfCheckFailing.Include) > 0 || len(data.SkipIfCheckFailing.Exclude) > 0 || data.SkipIfCheckFailing.Branch != "" { steps = append(steps, " env:\n") - if len(data.SkipIfCheckFailed.Include) > 0 { - includeJSON, _ := json.Marshal(data.SkipIfCheckFailed.Include) + if len(data.SkipIfCheckFailing.Include) > 0 { + includeJSON, _ := json.Marshal(data.SkipIfCheckFailing.Include) steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_CHECK_INCLUDE: %q\n", string(includeJSON))) } - if len(data.SkipIfCheckFailed.Exclude) > 0 { - excludeJSON, _ := json.Marshal(data.SkipIfCheckFailed.Exclude) + if len(data.SkipIfCheckFailing.Exclude) > 0 { + excludeJSON, _ := json.Marshal(data.SkipIfCheckFailing.Exclude) steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_CHECK_EXCLUDE: %q\n", string(excludeJSON))) } - if data.SkipIfCheckFailed.Branch != "" { - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_BRANCH: %q\n", data.SkipIfCheckFailed.Branch)) + if data.SkipIfCheckFailing.Branch != "" { + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_BRANCH: %q\n", data.SkipIfCheckFailing.Branch)) } } steps = append(steps, " with:\n") steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_check_failed.cjs")) + steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_check_failing.cjs")) } // Add skip-roles check if configured @@ -291,14 +291,14 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec conditions = append(conditions, skipNoMatchCheckOk) } - if data.SkipIfCheckFailed != nil { - // Add skip-if-check-failed check condition - skipIfCheckFailedOk := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfCheckFailedStepID, constants.SkipIfCheckFailedOkOutput)), + if data.SkipIfCheckFailing != nil { + // Add skip-if-check-failing check condition + skipIfCheckFailingOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfCheckFailingStepID, constants.SkipIfCheckFailingOkOutput)), "==", BuildStringLiteral("true"), ) - conditions = append(conditions, skipIfCheckFailedOk) + conditions = append(conditions, skipIfCheckFailingOk) } if len(data.SkipRoles) > 0 { diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 600ac92387..6a441ebd34 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -343,8 +343,8 @@ type SkipIfNoMatchConfig struct { // Auth (github-token / github-app) is taken from on.github-token / on.github-app at the top level. } -// SkipIfCheckFailedConfig holds the configuration for skip-if-check-failed conditions -type SkipIfCheckFailedConfig struct { +// SkipIfCheckFailingConfig holds the configuration for skip-if-check-failing conditions +type SkipIfCheckFailingConfig struct { Include []string // check names to include (empty = all checks) Exclude []string // check names to exclude Branch string // optional branch name to check (defaults to triggering ref or PR base branch) @@ -388,60 +388,60 @@ type WorkflowData struct { AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging StopTime string - SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold - SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold - SkipIfCheckFailed *SkipIfCheckFailedConfig // skip-if-check-failed configuration - SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) - SkipBots []string // users to skip workflow for (e.g., [user1, user2]) - OnSteps []map[string]any // steps to inject into the pre-activation job from on.steps - OnPermissions *Permissions // additional permissions for the pre-activation job from on.permissions - ManualApproval string // environment name for manual approval from on: section - Command []string // for /command trigger support - multiple command names - CommandEvents []string // events where command should be active (nil = all events) - CommandOtherEvents map[string]any // for merging command with other events - LabelCommand []string // for label-command trigger support - label names that act as commands - LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion) - LabelCommandOtherEvents map[string]any // for merging label-command with other events - LabelCommandRemoveLabel bool // whether to automatically remove the triggering label (default: true) - AIReaction string // AI reaction type like "eyes", "heart", etc. - StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) - ActivationGitHubToken string // custom github token from on.github-token for reactions/comments - ActivationGitHubApp *GitHubAppConfig // github app config from on.github-app for minting activation tokens - TopLevelGitHubApp *GitHubAppConfig // top-level github-app fallback for all nested github-app token minting operations - LockForAgent bool // whether to lock the issue during agent workflow execution - Jobs map[string]any // custom job configurations with dependencies - Cache string // cache configuration - NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} - NetworkPermissions *NetworkPermissions // parsed network permissions - SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) - SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes - MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools - Roles []string // permission levels required to trigger workflow - Bots []string // allow list of bot identifiers that can trigger workflow - RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers - CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration - RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration - Runtimes map[string]any // runtime version overrides from frontmatter - APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install - ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) - ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) - Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) - ActionCache *ActionCache // cache for action pin resolutions - ActionResolver *ActionResolver // resolver for action pins - StrictMode bool // strict mode for action pinning - SecretMasking *SecretMaskingConfig // secret masking configuration - ParsedFrontmatter *FrontmatterConfig // cached parsed frontmatter configuration (for performance optimization) - RawFrontmatter map[string]any // raw parsed frontmatter map (for passing to hash functions without re-parsing) - ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") - ActionMode ActionMode // action mode for workflow compilation (dev, release, script) - HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter - InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) - CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter - CheckoutDisabled bool // true when checkout: false is set in frontmatter - HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand) - ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) - IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) - EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps + SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold + SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold + SkipIfCheckFailing *SkipIfCheckFailingConfig // skip-if-check-failing configuration + SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) + SkipBots []string // users to skip workflow for (e.g., [user1, user2]) + OnSteps []map[string]any // steps to inject into the pre-activation job from on.steps + OnPermissions *Permissions // additional permissions for the pre-activation job from on.permissions + ManualApproval string // environment name for manual approval from on: section + Command []string // for /command trigger support - multiple command names + CommandEvents []string // events where command should be active (nil = all events) + CommandOtherEvents map[string]any // for merging command with other events + LabelCommand []string // for label-command trigger support - label names that act as commands + LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion) + LabelCommandOtherEvents map[string]any // for merging label-command with other events + LabelCommandRemoveLabel bool // whether to automatically remove the triggering label (default: true) + AIReaction string // AI reaction type like "eyes", "heart", etc. + StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) + ActivationGitHubToken string // custom github token from on.github-token for reactions/comments + ActivationGitHubApp *GitHubAppConfig // github app config from on.github-app for minting activation tokens + TopLevelGitHubApp *GitHubAppConfig // top-level github-app fallback for all nested github-app token minting operations + LockForAgent bool // whether to lock the issue during agent workflow execution + Jobs map[string]any // custom job configurations with dependencies + Cache string // cache configuration + NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} + NetworkPermissions *NetworkPermissions // parsed network permissions + SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) + SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes + MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools + Roles []string // permission levels required to trigger workflow + Bots []string // allow list of bot identifiers that can trigger workflow + RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers + CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration + RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration + Runtimes map[string]any // runtime version overrides from frontmatter + APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install + ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) + ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) + Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) + ActionCache *ActionCache // cache for action pin resolutions + ActionResolver *ActionResolver // resolver for action pins + StrictMode bool // strict mode for action pinning + SecretMasking *SecretMaskingConfig // secret masking configuration + ParsedFrontmatter *FrontmatterConfig // cached parsed frontmatter configuration (for performance optimization) + RawFrontmatter map[string]any // raw parsed frontmatter map (for passing to hash functions without re-parsing) + ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") + ActionMode ActionMode // action mode for workflow compilation (dev, release, script) + HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter + InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) + CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter + CheckoutDisabled bool // true when checkout: false is set in frontmatter + HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand) + ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) + IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) + EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps } // BaseSafeOutputConfig holds common configuration fields for all safe output types diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index 8bddab72fc..bcb252ea53 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -134,7 +134,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat inForksArray := false inSkipIfMatch := false inSkipIfNoMatch := false - inSkipIfCheckFailed := false + inSkipIfCheckFailing := false inSkipRolesArray := false inSkipBotsArray := false inRolesArray := false @@ -270,12 +270,12 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } } - // Check if we're entering skip-if-check-failed object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfCheckFailed { + // Check if we're entering skip-if-check-failing object + if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfCheckFailing { // Check both uncommented and commented forms - if trimmedLine == "skip-if-check-failed:" || - (strings.HasPrefix(trimmedLine, "# skip-if-check-failed:") && strings.Contains(trimmedLine, "pre-activation job")) { - inSkipIfCheckFailed = true + if trimmedLine == "skip-if-check-failing:" || + (strings.HasPrefix(trimmedLine, "# skip-if-check-failing:") && strings.Contains(trimmedLine, "pre-activation job")) { + inSkipIfCheckFailing = true } } @@ -314,16 +314,16 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } } - // Check if we're leaving skip-if-check-failed object (encountering another top-level field) - // Skip this check if we just entered skip-if-check-failed on this line - if inSkipIfCheckFailed && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "skip-if-check-failed:") && - !strings.HasPrefix(trimmedLine, "# skip-if-check-failed:") { + // Check if we're leaving skip-if-check-failing object (encountering another top-level field) + // Skip this check if we just entered skip-if-check-failing on this line + if inSkipIfCheckFailing && strings.TrimSpace(line) != "" && + !strings.HasPrefix(trimmedLine, "skip-if-check-failing:") && + !strings.HasPrefix(trimmedLine, "# skip-if-check-failing:") { // Get the indentation of the current line lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - // If this is a field at same level as skip-if-check-failed (2 spaces) and not a comment, we're out + // If this is a field at same level as skip-if-check-failing (2 spaces) and not a comment, we're out if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inSkipIfCheckFailed = false + inSkipIfCheckFailing = false } } @@ -440,11 +440,11 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat // Comment out nested fields in skip-if-no-match object shouldComment = true commentReason = "" - } else if strings.HasPrefix(trimmedLine, "skip-if-check-failed:") { + } else if strings.HasPrefix(trimmedLine, "skip-if-check-failing:") { shouldComment = true - commentReason = " # Skip-if-check-failed processed as check status gate in pre-activation job" - } else if inSkipIfCheckFailed && (strings.HasPrefix(trimmedLine, "include:") || strings.HasPrefix(trimmedLine, "exclude:") || strings.HasPrefix(trimmedLine, "branch:") || strings.HasPrefix(trimmedLine, "-")) { - // Comment out nested fields and list items in skip-if-check-failed object + commentReason = " # Skip-if-check-failing processed as check status gate in pre-activation job" + } else if inSkipIfCheckFailing && (strings.HasPrefix(trimmedLine, "include:") || strings.HasPrefix(trimmedLine, "exclude:") || strings.HasPrefix(trimmedLine, "branch:") || strings.HasPrefix(trimmedLine, "-")) { + // Comment out nested fields and list items in skip-if-check-failing object shouldComment = true commentReason = "" } else if strings.HasPrefix(trimmedLine, "skip-roles:") { diff --git a/pkg/workflow/skip_if_check_failed_test.go b/pkg/workflow/skip_if_check_failing_test.go similarity index 71% rename from pkg/workflow/skip_if_check_failed_test.go rename to pkg/workflow/skip_if_check_failing_test.go index bf94d64eee..93e11969a2 100644 --- a/pkg/workflow/skip_if_check_failed_test.go +++ b/pkg/workflow/skip_if_check_failing_test.go @@ -13,26 +13,26 @@ import ( "github.com/github/gh-aw/pkg/testutil" ) -// TestSkipIfCheckFailedPreActivationJob tests that skip-if-check-failed check is created correctly in pre-activation job -func TestSkipIfCheckFailedPreActivationJob(t *testing.T) { - tmpDir := testutil.TempDir(t, "skip-if-check-failed-test") +// TestSkipIfCheckFailingPreActivationJob tests that skip-if-check-failing check is created correctly in pre-activation job +func TestSkipIfCheckFailingPreActivationJob(t *testing.T) { + tmpDir := testutil.TempDir(t, "skip-if-check-failing-test") compiler := NewCompiler() - t.Run("pre_activation_job_created_with_skip_if_check_failed_boolean", func(t *testing.T) { + t.Run("pre_activation_job_created_with_skip_if_check_failing_boolean", func(t *testing.T) { workflowContent := `--- on: pull_request: types: [opened, synchronize] - skip-if-check-failed: true + skip-if-check-failing: true engine: claude --- -# Skip If Check Failed Workflow +# Skip If Check Failing Workflow -This workflow has a skip-if-check-failed configuration. +This workflow has a skip-if-check-failing configuration. ` - workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-workflow.md") + workflowFile := filepath.Join(tmpDir, "skip-if-check-failing-workflow.md") if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { t.Fatal(err) } @@ -55,37 +55,37 @@ This workflow has a skip-if-check-failed configuration. t.Error("Expected pre_activation job to be created") } - // Verify skip-if-check-failed check step is present - if !strings.Contains(lockContentStr, "Check skip-if-check-failed") { - t.Error("Expected skip-if-check-failed check step to be present") + // Verify skip-if-check-failing check step is present + if !strings.Contains(lockContentStr, "Check skip-if-check-failing") { + t.Error("Expected skip-if-check-failing check step to be present") } // Verify the step ID is set - if !strings.Contains(lockContentStr, "id: check_skip_if_check_failed") { - t.Error("Expected check_skip_if_check_failed step ID") + if !strings.Contains(lockContentStr, "id: check_skip_if_check_failing") { + t.Error("Expected check_skip_if_check_failing step ID") } // Verify the activated output includes the check condition - if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok") { - t.Error("Expected activated output to include skip_if_check_failed_ok condition") + if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failing.outputs.skip_if_check_failing_ok") { + t.Error("Expected activated output to include skip_if_check_failing_ok condition") } - // Verify skip-if-check-failed is commented out in the frontmatter - if !strings.Contains(lockContentStr, "# skip-if-check-failed:") { - t.Error("Expected skip-if-check-failed to be commented out in lock file") + // Verify skip-if-check-failing is commented out in the frontmatter + if !strings.Contains(lockContentStr, "# skip-if-check-failing:") { + t.Error("Expected skip-if-check-failing to be commented out in lock file") } - if !strings.Contains(lockContentStr, "Skip-if-check-failed processed as check status gate in pre-activation job") { - t.Error("Expected comment explaining skip-if-check-failed processing") + if !strings.Contains(lockContentStr, "Skip-if-check-failing processed as check status gate in pre-activation job") { + t.Error("Expected comment explaining skip-if-check-failing processing") } }) - t.Run("pre_activation_job_created_with_skip_if_check_failed_object_with_include_and_exclude", func(t *testing.T) { + t.Run("pre_activation_job_created_with_skip_if_check_failing_object_with_include_and_exclude", func(t *testing.T) { workflowContent := `--- on: pull_request: types: [opened, synchronize] - skip-if-check-failed: + skip-if-check-failing: include: - build - test @@ -95,11 +95,11 @@ on: engine: claude --- -# Skip If Check Failed Object Form +# Skip If Check Failing Object Form -This workflow uses the object form of skip-if-check-failed. +This workflow uses the object form of skip-if-check-failing. ` - workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-object-workflow.md") + workflowFile := filepath.Join(tmpDir, "skip-if-check-failing-object-workflow.md") if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { t.Fatal(err) } @@ -117,9 +117,9 @@ This workflow uses the object form of skip-if-check-failed. lockContentStr := string(lockContent) - // Verify skip-if-check-failed check step is present - if !strings.Contains(lockContentStr, "Check skip-if-check-failed") { - t.Error("Expected skip-if-check-failed check step to be present") + // Verify skip-if-check-failing check step is present + if !strings.Contains(lockContentStr, "Check skip-if-check-failing") { + t.Error("Expected skip-if-check-failing check step to be present") } // Verify include list is passed as JSON env var @@ -138,17 +138,17 @@ This workflow uses the object form of skip-if-check-failed. } // Verify condition is in activated output - if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok") { - t.Error("Expected activated output to include skip_if_check_failed_ok condition") + if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failing.outputs.skip_if_check_failing_ok") { + t.Error("Expected activated output to include skip_if_check_failing_ok condition") } }) - t.Run("skip_if_check_failed_no_env_vars_when_bare_true", func(t *testing.T) { + t.Run("skip_if_check_failing_no_env_vars_when_bare_true", func(t *testing.T) { workflowContent := `--- on: schedule: - cron: "*/30 * * * *" - skip-if-check-failed: true + skip-if-check-failing: true engine: claude --- @@ -156,7 +156,7 @@ engine: claude Skips if any checks fail on the default branch. ` - workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-bare-workflow.md") + workflowFile := filepath.Join(tmpDir, "skip-if-check-failing-bare-workflow.md") if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { t.Fatal(err) } @@ -186,13 +186,13 @@ Skips if any checks fail on the default branch. } }) - t.Run("skip_if_check_failed_combined_with_other_gates", func(t *testing.T) { + t.Run("skip_if_check_failing_combined_with_other_gates", func(t *testing.T) { workflowContent := `--- on: pull_request: types: [opened, synchronize] skip-if-match: "is:pr is:open label:blocked" - skip-if-check-failed: + skip-if-check-failing: include: - build roles: [admin, maintainer] @@ -228,17 +228,17 @@ This workflow combines multiple gate types. if !strings.Contains(lockContentStr, "steps.check_skip_if_match.outputs.skip_check_ok == 'true'") { t.Error("Expected skip_check_ok condition in activated output") } - if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failed.outputs.skip_if_check_failed_ok == 'true'") { - t.Error("Expected skip_if_check_failed_ok condition in activated output") + if !strings.Contains(lockContentStr, "steps.check_skip_if_check_failing.outputs.skip_if_check_failing_ok == 'true'") { + t.Error("Expected skip_if_check_failing_ok condition in activated output") } }) - t.Run("skip_if_check_failed_object_without_branch", func(t *testing.T) { + t.Run("skip_if_check_failing_object_without_branch", func(t *testing.T) { workflowContent := `--- on: pull_request: types: [opened] - skip-if-check-failed: + skip-if-check-failing: exclude: - spelling engine: claude @@ -248,7 +248,7 @@ engine: claude Skips if non-spelling checks fail. ` - workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-no-branch.md") + workflowFile := filepath.Join(tmpDir, "skip-if-check-failing-no-branch.md") if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { t.Fatal(err) } @@ -274,21 +274,21 @@ Skips if non-spelling checks fail. } }) - t.Run("skip_if_check_failed_null_value_treated_as_true", func(t *testing.T) { - // skip-if-check-failed: (no value / YAML null) should behave identically to skip-if-check-failed: true + t.Run("skip_if_check_failing_null_value_treated_as_true", func(t *testing.T) { + // skip-if-check-failing: (no value / YAML null) should behave identically to skip-if-check-failing: true workflowContent := `--- on: pull_request: types: [opened, synchronize] - skip-if-check-failed: + skip-if-check-failing: engine: claude --- -# Skip If Check Failed Null Value +# Skip If Check Failing Null Value -This workflow uses the bare null form of skip-if-check-failed. +This workflow uses the bare null form of skip-if-check-failing. ` - workflowFile := filepath.Join(tmpDir, "skip-if-check-failed-null-workflow.md") + workflowFile := filepath.Join(tmpDir, "skip-if-check-failing-null-workflow.md") if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { t.Fatal(err) } @@ -306,12 +306,12 @@ This workflow uses the bare null form of skip-if-check-failed. lockContentStr := string(lockContent) - // Should produce the check step, just like skip-if-check-failed: true - if !strings.Contains(lockContentStr, "Check skip-if-check-failed") { - t.Error("Expected skip-if-check-failed check step to be present") + // Should produce the check step, just like skip-if-check-failing: true + if !strings.Contains(lockContentStr, "Check skip-if-check-failing") { + t.Error("Expected skip-if-check-failing check step to be present") } - if !strings.Contains(lockContentStr, "id: check_skip_if_check_failed") { - t.Error("Expected check_skip_if_check_failed step ID") + if !strings.Contains(lockContentStr, "id: check_skip_if_check_failing") { + t.Error("Expected check_skip_if_check_failing step ID") } // No env vars since no include/exclude/branch if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_INCLUDE") { diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index 483b955833..a61dedffb4 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -404,8 +404,8 @@ func (c *Compiler) processSkipIfNoMatchConfiguration(frontmatter map[string]any, return nil } -// extractSkipIfCheckFailedFromOn extracts the skip-if-check-failed value from the on: section -func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, workflowData ...*WorkflowData) (*SkipIfCheckFailedConfig, error) { +// extractSkipIfCheckFailingFromOn extracts the skip-if-check-failing value from the on: section +func (c *Compiler) extractSkipIfCheckFailingFromOn(frontmatter map[string]any, workflowData ...*WorkflowData) (*SkipIfCheckFailingConfig, error) { // Use cached On field from ParsedFrontmatter if available (when workflowData is provided) var onSection any var exists bool @@ -423,35 +423,35 @@ func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, wo // Handle different formats of the on: section switch on := onSection.(type) { case string: - // Simple string format like "on: push" - no skip-if-check-failed possible + // Simple string format like "on: push" - no skip-if-check-failing possible return nil, nil case map[string]any: - // Complex object format - look for skip-if-check-failed - if skipIfCheckFailed, exists := on["skip-if-check-failed"]; exists { - switch skip := skipIfCheckFailed.(type) { + // Complex object format - look for skip-if-check-failing + if skipIfCheckFailing, exists := on["skip-if-check-failing"]; exists { + switch skip := skipIfCheckFailing.(type) { case nil: - // Null value (bare key with no value): skip-if-check-failed: - return &SkipIfCheckFailedConfig{}, nil + // Null value (bare key with no value): skip-if-check-failing: + return &SkipIfCheckFailingConfig{}, nil case bool: - // Simple boolean format: skip-if-check-failed: true + // Simple boolean format: skip-if-check-failing: true if !skip { - return nil, errors.New("skip-if-check-failed: false is not valid; remove the field to disable the check") + return nil, errors.New("skip-if-check-failing: false is not valid; remove the field to disable the check") } - return &SkipIfCheckFailedConfig{}, nil + return &SkipIfCheckFailingConfig{}, nil case map[string]any: - // Object format: skip-if-check-failed: { include: [...], exclude: [...], branch: "..." } - config := &SkipIfCheckFailedConfig{} + // Object format: skip-if-check-failing: { include: [...], exclude: [...], branch: "..." } + config := &SkipIfCheckFailingConfig{} // Extract include list (optional) if includeRaw, hasInclude := skip["include"]; hasInclude { includeSlice, ok := includeRaw.([]any) if !ok { - return nil, errors.New("skip-if-check-failed 'include' field must be a list of strings. Example:\n skip-if-check-failed:\n include:\n - build\n - test") + return nil, errors.New("skip-if-check-failing 'include' field must be a list of strings. Example:\n skip-if-check-failing:\n include:\n - build\n - test") } for _, item := range includeSlice { s, ok := item.(string) if !ok { - return nil, fmt.Errorf("skip-if-check-failed 'include' list items must be strings, got %T", item) + return nil, fmt.Errorf("skip-if-check-failing 'include' list items must be strings, got %T", item) } config.Include = append(config.Include, s) } @@ -461,12 +461,12 @@ func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, wo if excludeRaw, hasExclude := skip["exclude"]; hasExclude { excludeSlice, ok := excludeRaw.([]any) if !ok { - return nil, errors.New("skip-if-check-failed 'exclude' field must be a list of strings. Example:\n skip-if-check-failed:\n exclude:\n - lint") + return nil, errors.New("skip-if-check-failing 'exclude' field must be a list of strings. Example:\n skip-if-check-failing:\n exclude:\n - lint") } for _, item := range excludeSlice { s, ok := item.(string) if !ok { - return nil, fmt.Errorf("skip-if-check-failed 'exclude' list items must be strings, got %T", item) + return nil, fmt.Errorf("skip-if-check-failing 'exclude' list items must be strings, got %T", item) } config.Exclude = append(config.Exclude, s) } @@ -476,14 +476,14 @@ func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, wo if branchRaw, hasBranch := skip["branch"]; hasBranch { branchStr, ok := branchRaw.(string) if !ok { - return nil, fmt.Errorf("skip-if-check-failed 'branch' field must be a string, got %T. Example: branch: main", branchRaw) + return nil, fmt.Errorf("skip-if-check-failing 'branch' field must be a string, got %T. Example: branch: main", branchRaw) } config.Branch = branchStr } return config, nil default: - return nil, fmt.Errorf("skip-if-check-failed value must be true or an object, got %T. Examples:\n skip-if-check-failed:\n skip-if-check-failed: true\n skip-if-check-failed:\n include:\n - build\n branch: main", skipIfCheckFailed) + return nil, fmt.Errorf("skip-if-check-failing value must be true or an object, got %T. Examples:\n skip-if-check-failing:\n skip-if-check-failing: true\n skip-if-check-failing:\n include:\n - build\n branch: main", skipIfCheckFailing) } } return nil, nil @@ -492,19 +492,19 @@ func (c *Compiler) extractSkipIfCheckFailedFromOn(frontmatter map[string]any, wo } } -// processSkipIfCheckFailedConfiguration extracts and processes skip-if-check-failed configuration from frontmatter -func (c *Compiler) processSkipIfCheckFailedConfiguration(frontmatter map[string]any, workflowData *WorkflowData) error { - skipIfCheckFailedConfig, err := c.extractSkipIfCheckFailedFromOn(frontmatter, workflowData) +// processSkipIfCheckFailingConfiguration extracts and processes skip-if-check-failing configuration from frontmatter +func (c *Compiler) processSkipIfCheckFailingConfiguration(frontmatter map[string]any, workflowData *WorkflowData) error { + skipIfCheckFailingConfig, err := c.extractSkipIfCheckFailingFromOn(frontmatter, workflowData) if err != nil { return err } - workflowData.SkipIfCheckFailed = skipIfCheckFailedConfig + workflowData.SkipIfCheckFailing = skipIfCheckFailingConfig - if workflowData.SkipIfCheckFailed != nil { - stopAfterLog.Printf("Skip-if-check-failed configured: include=%v, exclude=%v, branch=%q", - workflowData.SkipIfCheckFailed.Include, - workflowData.SkipIfCheckFailed.Exclude, - workflowData.SkipIfCheckFailed.Branch, + if workflowData.SkipIfCheckFailing != nil { + stopAfterLog.Printf("Skip-if-check-failing configured: include=%v, exclude=%v, branch=%q", + workflowData.SkipIfCheckFailing.Include, + workflowData.SkipIfCheckFailing.Exclude, + workflowData.SkipIfCheckFailing.Branch, ) } From b721ebb6c9e7bf5ddbd7860213606bec1d1c32d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:16:39 +0000 Subject: [PATCH 6/7] feat: treat pending checks as failing by default, add allow-pending option to skip-if-check-failing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/671c67d4-a487-4445-bee7-caf0834cf026 --- .../setup/js/check_skip_if_check_failing.cjs | 18 +++++-- .../js/check_skip_if_check_failing.test.cjs | 40 +++++++++++++++- pkg/parser/schemas/main_workflow_schema.json | 8 +++- pkg/workflow/compiler_pre_activation_job.go | 5 +- pkg/workflow/compiler_types.go | 7 +-- pkg/workflow/frontmatter_extraction_yaml.go | 2 +- pkg/workflow/skip_if_check_failing_test.go | 47 +++++++++++++++++++ pkg/workflow/stop_after.go | 16 +++++-- 8 files changed, 129 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/check_skip_if_check_failing.cjs b/actions/setup/js/check_skip_if_check_failing.cjs index 51894b450a..42eae9233e 100644 --- a/actions/setup/js/check_skip_if_check_failing.cjs +++ b/actions/setup/js/check_skip_if_check_failing.cjs @@ -70,6 +70,7 @@ function isDeploymentCheck(run) { async function main() { const includeEnv = process.env.GH_AW_SKIP_CHECK_INCLUDE; const excludeEnv = process.env.GH_AW_SKIP_CHECK_EXCLUDE; + const allowPending = process.env.GH_AW_SKIP_CHECK_ALLOW_PENDING === "true"; const includeList = parseListEnv(includeEnv); const excludeList = parseListEnv(excludeEnv); @@ -89,6 +90,9 @@ async function main() { if (excludeList && excludeList.length > 0) { core.info(`Excluding checks: ${excludeList.join(", ")}`); } + if (allowPending) { + core.info("Pending/in-progress checks will be ignored (allow-pending: true)"); + } try { // Fetch all check runs for the ref (paginate to handle repos with many checks) @@ -136,13 +140,21 @@ async function main() { core.info(`Evaluating ${relevant.length} check run(s) after filtering`); - // A check is considered "failed" if it has completed with a non-success conclusion + // A check is "failing" if it either: + // 1. Completed with a non-success conclusion (failure, cancelled, timed_out), OR + // 2. Is still pending/in-progress — unless allow-pending is set const failedConclusions = new Set(["failure", "cancelled", "timed_out"]); - const failingChecks = relevant.filter(run => run.status === "completed" && run.conclusion != null && failedConclusions.has(run.conclusion)); + const failingChecks = relevant.filter(run => { + if (run.status === "completed") { + return run.conclusion != null && failedConclusions.has(run.conclusion); + } + // Pending/queued/in_progress: treat as failing unless allow-pending is true + return !allowPending; + }); if (failingChecks.length > 0) { - const names = failingChecks.map(r => `${r.name} (${r.conclusion})`).join(", "); + const names = failingChecks.map(r => (r.status === "completed" ? `${r.name} (${r.conclusion})` : `${r.name} (${r.status})`)).join(", "); core.warning(`⚠️ Failing CI checks detected on "${ref}": ${names}. Workflow execution will be prevented by activation job.`); core.setOutput("skip_if_check_failing_ok", "false"); return; diff --git a/actions/setup/js/check_skip_if_check_failing.test.cjs b/actions/setup/js/check_skip_if_check_failing.test.cjs index 7ba3237e1d..2e1f76b217 100644 --- a/actions/setup/js/check_skip_if_check_failing.test.cjs +++ b/actions/setup/js/check_skip_if_check_failing.test.cjs @@ -45,6 +45,7 @@ describe("check_skip_if_check_failing.cjs", () => { delete process.env.GH_AW_SKIP_CHECK_INCLUDE; delete process.env.GH_AW_SKIP_CHECK_EXCLUDE; delete process.env.GITHUB_BASE_REF; + delete process.env.GH_AW_SKIP_CHECK_ALLOW_PENDING; }); it("should allow workflow when all checks pass", async () => { @@ -93,13 +94,50 @@ describe("check_skip_if_check_failing.cjs", () => { expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); }); - it("should allow workflow when checks are still in progress", async () => { + it("should cancel workflow when checks are still in progress (pending treated as failing by default)", async () => { + mockGithub.paginate.mockResolvedValue([{ name: "build", status: "in_progress", conclusion: null, started_at: "2024-01-01T00:00:00Z" }]); + + const { main } = await import("./check_skip_if_check_failing.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("build (in_progress)")); + }); + + it("should cancel workflow when checks are queued (pending treated as failing by default)", async () => { + mockGithub.paginate.mockResolvedValue([{ name: "test", status: "queued", conclusion: null, started_at: null }]); + + const { main } = await import("./check_skip_if_check_failing.cjs"); + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("test (queued)")); + }); + + it("should allow workflow when checks are in progress and allow-pending is true", async () => { + process.env.GH_AW_SKIP_CHECK_ALLOW_PENDING = "true"; mockGithub.paginate.mockResolvedValue([{ name: "build", status: "in_progress", conclusion: null, started_at: "2024-01-01T00:00:00Z" }]); const { main } = await import("./check_skip_if_check_failing.cjs"); await main(); expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should cancel when a completed check fails even with allow-pending true", async () => { + process.env.GH_AW_SKIP_CHECK_ALLOW_PENDING = "true"; + mockGithub.paginate.mockResolvedValue([ + { name: "build", status: "in_progress", conclusion: null, started_at: "2024-01-01T00:00:00Z" }, + { name: "lint", status: "completed", conclusion: "failure", started_at: "2024-01-01T00:00:00Z" }, + ]); + + const { main } = await import("./check_skip_if_check_failing.cjs"); + await main(); + + // lint failed → cancel; build pending but ignored due to allow-pending + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_if_check_failing_ok", "false"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("lint")); }); it("should allow workflow when no checks exist", async () => { diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7e4628a8d3..3cdbed8e95 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1502,13 +1502,17 @@ "branch": { "type": "string", "description": "Branch name to check for failing CI checks. When omitted, defaults to the base branch of a pull_request event or the current ref for other events." + }, + "allow-pending": { + "type": "boolean", + "description": "When true, pending or in-progress checks are not treated as failing. By default (false), any check that has not yet completed is treated as failing and will block the workflow." } }, "additionalProperties": false, - "description": "Skip-if-check-failing configuration object with optional include/exclude filter lists and an optional branch name." + "description": "Skip-if-check-failing configuration object with optional include/exclude filter lists, an optional branch name, and an allow-pending flag." } ], - "description": "Skip workflow execution if any CI checks on the target branch are failing. Accepts true (check all) or an object to filter specific checks by name and optionally specify a branch." + "description": "Skip workflow execution if any CI checks on the target branch are failing or pending. Accepts true (check all) or an object to filter specific checks by name and optionally specify a branch or allow pending checks." }, "skip-roles": { "oneOf": [ diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 9ceced8bda..184e9a1c17 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -156,7 +156,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, " - name: Check skip-if-check-failing\n") steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfCheckFailingStepID)) steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - if len(data.SkipIfCheckFailing.Include) > 0 || len(data.SkipIfCheckFailing.Exclude) > 0 || data.SkipIfCheckFailing.Branch != "" { + if len(data.SkipIfCheckFailing.Include) > 0 || len(data.SkipIfCheckFailing.Exclude) > 0 || data.SkipIfCheckFailing.Branch != "" || data.SkipIfCheckFailing.AllowPending { steps = append(steps, " env:\n") if len(data.SkipIfCheckFailing.Include) > 0 { includeJSON, _ := json.Marshal(data.SkipIfCheckFailing.Include) @@ -169,6 +169,9 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec if data.SkipIfCheckFailing.Branch != "" { steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_BRANCH: %q\n", data.SkipIfCheckFailing.Branch)) } + if data.SkipIfCheckFailing.AllowPending { + steps = append(steps, " GH_AW_SKIP_CHECK_ALLOW_PENDING: \"true\"\n") + } } steps = append(steps, " with:\n") steps = append(steps, " script: |\n") diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 6a441ebd34..40c8841c98 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -345,9 +345,10 @@ type SkipIfNoMatchConfig struct { // SkipIfCheckFailingConfig holds the configuration for skip-if-check-failing conditions type SkipIfCheckFailingConfig struct { - Include []string // check names to include (empty = all checks) - Exclude []string // check names to exclude - Branch string // optional branch name to check (defaults to triggering ref or PR base branch) + Include []string // check names to include (empty = all checks) + Exclude []string // check names to exclude + Branch string // optional branch name to check (defaults to triggering ref or PR base branch) + AllowPending bool // if true, pending/in-progress checks are not treated as failing (default: treat pending as failing) } type WorkflowData struct { Name string diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index bcb252ea53..775c08eb45 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -443,7 +443,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } else if strings.HasPrefix(trimmedLine, "skip-if-check-failing:") { shouldComment = true commentReason = " # Skip-if-check-failing processed as check status gate in pre-activation job" - } else if inSkipIfCheckFailing && (strings.HasPrefix(trimmedLine, "include:") || strings.HasPrefix(trimmedLine, "exclude:") || strings.HasPrefix(trimmedLine, "branch:") || strings.HasPrefix(trimmedLine, "-")) { + } else if inSkipIfCheckFailing && (strings.HasPrefix(trimmedLine, "include:") || strings.HasPrefix(trimmedLine, "exclude:") || strings.HasPrefix(trimmedLine, "branch:") || strings.HasPrefix(trimmedLine, "allow-pending:") || strings.HasPrefix(trimmedLine, "-")) { // Comment out nested fields and list items in skip-if-check-failing object shouldComment = true commentReason = "" diff --git a/pkg/workflow/skip_if_check_failing_test.go b/pkg/workflow/skip_if_check_failing_test.go index 93e11969a2..3ace98bd10 100644 --- a/pkg/workflow/skip_if_check_failing_test.go +++ b/pkg/workflow/skip_if_check_failing_test.go @@ -324,4 +324,51 @@ This workflow uses the bare null form of skip-if-check-failing. t.Error("Expected no GH_AW_SKIP_BRANCH for bare null form") } }) + + t.Run("skip_if_check_failing_allow_pending_sets_env_var", func(t *testing.T) { + workflowContent := `--- +on: + pull_request: + types: [opened, synchronize] + skip-if-check-failing: + allow-pending: true +engine: claude +--- + +# Skip If Check Failing Allow Pending + +This workflow allows pending checks. +` + workflowFile := filepath.Join(tmpDir, "skip-if-check-failing-allow-pending.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + if !strings.Contains(lockContentStr, "Check skip-if-check-failing") { + t.Error("Expected skip-if-check-failing check step to be present") + } + if !strings.Contains(lockContentStr, `GH_AW_SKIP_CHECK_ALLOW_PENDING: "true"`) { + t.Error("Expected GH_AW_SKIP_CHECK_ALLOW_PENDING env var when allow-pending: true") + } + // No include/exclude/branch since only allow-pending was set + if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_INCLUDE") { + t.Error("Expected no GH_AW_SKIP_CHECK_INCLUDE") + } + if strings.Contains(lockContentStr, "GH_AW_SKIP_CHECK_EXCLUDE") { + t.Error("Expected no GH_AW_SKIP_CHECK_EXCLUDE") + } + }) } diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index a61dedffb4..2fcc218d00 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -439,7 +439,7 @@ func (c *Compiler) extractSkipIfCheckFailingFromOn(frontmatter map[string]any, w } return &SkipIfCheckFailingConfig{}, nil case map[string]any: - // Object format: skip-if-check-failing: { include: [...], exclude: [...], branch: "..." } + // Object format: skip-if-check-failing: { include: [...], exclude: [...], branch: "...", allow-pending: true } config := &SkipIfCheckFailingConfig{} // Extract include list (optional) @@ -481,9 +481,18 @@ func (c *Compiler) extractSkipIfCheckFailingFromOn(frontmatter map[string]any, w config.Branch = branchStr } + // Extract allow-pending (optional, defaults to false — pending counts as failing) + if allowPendingRaw, hasAllowPending := skip["allow-pending"]; hasAllowPending { + allowPending, ok := allowPendingRaw.(bool) + if !ok { + return nil, fmt.Errorf("skip-if-check-failing 'allow-pending' field must be a boolean, got %T. Example: allow-pending: true", allowPendingRaw) + } + config.AllowPending = allowPending + } + return config, nil default: - return nil, fmt.Errorf("skip-if-check-failing value must be true or an object, got %T. Examples:\n skip-if-check-failing:\n skip-if-check-failing: true\n skip-if-check-failing:\n include:\n - build\n branch: main", skipIfCheckFailing) + return nil, fmt.Errorf("skip-if-check-failing value must be true or an object, got %T. Examples:\n skip-if-check-failing:\n skip-if-check-failing: true\n skip-if-check-failing:\n include:\n - build\n branch: main\n allow-pending: true", skipIfCheckFailing) } } return nil, nil @@ -501,10 +510,11 @@ func (c *Compiler) processSkipIfCheckFailingConfiguration(frontmatter map[string workflowData.SkipIfCheckFailing = skipIfCheckFailingConfig if workflowData.SkipIfCheckFailing != nil { - stopAfterLog.Printf("Skip-if-check-failing configured: include=%v, exclude=%v, branch=%q", + stopAfterLog.Printf("Skip-if-check-failing configured: include=%v, exclude=%v, branch=%q, allow-pending=%v", workflowData.SkipIfCheckFailing.Include, workflowData.SkipIfCheckFailing.Exclude, workflowData.SkipIfCheckFailing.Branch, + workflowData.SkipIfCheckFailing.AllowPending, ) } From f87ac1118bb7aed1b798cfa2fee0faeb2d9a5910 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:41:17 +0000 Subject: [PATCH 7/7] chore: merge main and recompile workflows Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e47e315e-1d78-4568-89a1-9662a2789fa7 --- .github/workflows/unbloat-docs.lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 60aed0511f..ff647a0116 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -37,7 +37,7 @@ name: "Documentation Unbloat" - created - edited schedule: - - cron: "7 19 * * *" + - cron: "37 2 * * *" workflow_dispatch: inputs: aw_context: