From b2754cd9a5614fd4d3c679c857a2a5307a72d534 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:45:57 +0000 Subject: [PATCH 1/5] Initial plan From 90eb2a6e3eb8c059fc36c4d160cb6f80aff8612b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:07:54 +0000 Subject: [PATCH 2/5] Sync action-failure expiry marker with maintenance workflow generation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/handle_agent_failure.cjs | 59 ++++-- .../setup/js/handle_agent_failure.test.cjs | 60 +++++- docs/src/content/docs/reference/ephemerals.md | 2 + pkg/workflow/maintenance_workflow.go | 99 +++++++++- pkg/workflow/maintenance_workflow_test.go | 172 +++++++++++++++++- pkg/workflow/repo_config.go | 27 ++- pkg/workflow/repo_config_test.go | 12 ++ 7 files changed, 408 insertions(+), 23 deletions(-) diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index 3b75e2c0b96..45ca06928cf 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -55,12 +55,21 @@ const ALLOWED_FILES_ERROR_RE = /^(?.*outside the allowed-files list) \( /** * Parse action failure issue expiration from environment. - * @returns {number} Expiration in hours (defaults to 168 when unset/invalid) + * + * A value of "0" is an explicit signal from the compiler that no maintenance + * workflow will exist to enforce expiration, so expiration must be disabled + * (no expiration marker is written to failure issues). Missing/invalid values + * fall back to the 168-hour default for backwards compatibility with older + * generated lock files that always set a positive value. + * @returns {number} Expiration in hours (0 means disabled; defaults to 168 when unset/invalid) */ function getActionFailureIssueExpiresHours() { const raw = process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS || ""; + if (raw === "") { + return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS; + } const parsed = Number.parseInt(raw, 10); - if (Number.isInteger(parsed) && parsed > 0) { + if (Number.isInteger(parsed) && parsed >= 0) { return parsed; } return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS; @@ -590,24 +599,46 @@ async function ensureParentIssue(previousParentNumber = null, ownerOverride, rep const existingIssue = searchResult.data.items[0]; core.info(`Found existing parent issue #${existingIssue.number}: ${existingIssue.html_url}`); - // Check the sub-issue count - const subIssueCount = await getSubIssueCount(owner, repo, existingIssue.number); + // Enforce the parent issue's own expiration marker: an expired parent + // must not keep receiving new sub-issues, mirroring isReusableFailureIssue's + // handling of individual per-run failure issues. + let existingBody = typeof existingIssue.body === "string" ? existingIssue.body : ""; + if (!existingBody) { + const issueResult = await github.rest.issues.get({ + owner, + repo, + issue_number: existingIssue.number, + }); + existingBody = issueResult.data.body || ""; + } + const parentExpirationDate = extractExpirationDate(existingBody); - if (subIssueCount !== null && subIssueCount >= MAX_SUB_ISSUES) { - core.warning(`Parent issue #${existingIssue.number} has ${subIssueCount} sub-issues (max: ${MAX_SUB_ISSUES})`); - core.info(`Creating a new parent issue (previous parent #${existingIssue.number} is full)`); + if (parentExpirationDate && parentExpirationDate.getTime() <= Date.now()) { + core.info(`Parent issue #${existingIssue.number} has expired (expired ${parentExpirationDate.toISOString()})`); + core.info(`Creating a new parent issue (previous parent #${existingIssue.number} has expired)`); // Fall through to create a new parent issue, passing the previous parent number previousParentNumber = existingIssue.number; } else { - // Parent issue is within limits, return it - if (subIssueCount !== null) { - core.info(`Parent issue has ${subIssueCount} sub-issues (within limit of ${MAX_SUB_ISSUES})`); + // Check the sub-issue count + const subIssueCount = await getSubIssueCount(owner, repo, existingIssue.number); + + if (subIssueCount !== null && subIssueCount >= MAX_SUB_ISSUES) { + core.warning(`Parent issue #${existingIssue.number} has ${subIssueCount} sub-issues (max: ${MAX_SUB_ISSUES})`); + core.info(`Creating a new parent issue (previous parent #${existingIssue.number} is full)`); + + // Fall through to create a new parent issue, passing the previous parent number + previousParentNumber = existingIssue.number; + } else { + // Parent issue is within limits, return it + if (subIssueCount !== null) { + core.info(`Parent issue has ${subIssueCount} sub-issues (within limit of ${MAX_SUB_ISSUES})`); + } + return { + number: existingIssue.number, + node_id: existingIssue.node_id, + }; } - return { - number: existingIssue.number, - node_id: existingIssue.node_id, - }; } } } catch (error) { diff --git a/actions/setup/js/handle_agent_failure.test.cjs b/actions/setup/js/handle_agent_failure.test.cjs index ecc666b34cb..3e39dd7fcae 100644 --- a/actions/setup/js/handle_agent_failure.test.cjs +++ b/actions/setup/js/handle_agent_failure.test.cjs @@ -74,9 +74,12 @@ describe("handle_agent_failure", () => { expect(getActionFailureIssueExpiresHours()).toBe(48); }); - it("returns default for invalid values", () => { + it("returns 0 (disabled) when the compiler explicitly opts out of expiration", () => { process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "0"; - expect(getActionFailureIssueExpiresHours()).toBe(168); + expect(getActionFailureIssueExpiresHours()).toBe(0); + }); + + it("returns default for invalid values", () => { process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "invalid"; expect(getActionFailureIssueExpiresHours()).toBe(168); }); @@ -704,6 +707,59 @@ describe("handle_agent_failure", () => { expect(searchMock).toHaveBeenCalledWith(expect.objectContaining({ q: expect.stringContaining('"[aw] Failed runs"') })); }); + it("creates a new parent issue when the existing parent issue has expired", async () => { + const createCommentMock = vi.fn(); + const createIssueMock = vi.fn(async ({ title }) => ({ + data: { + number: title === "[aw] Failed runs" ? 300 : 301, + html_url: `https://github.com/owner/repo/issues/${title === "[aw] Failed runs" ? 300 : 301}`, + node_id: title === "[aw] Failed runs" ? "I_parent_new" : "I_child", + }, + })); + const expiredParentBody = "This issue tracks failures.\n\n> - [x] expires on Jan 1, 2000, 12:00 AM UTC"; + const searchMock = vi.fn(async ({ q }) => { + if (q.includes("is:pr")) { + return { data: { total_count: 0, items: [] } }; + } + if (q.includes('"[aw] Failed runs"')) { + return { + data: { + total_count: 1, + items: [{ number: 199, html_url: "https://github.com/owner/repo/issues/199", node_id: "I_parent_old", body: expiredParentBody }], + }, + }; + } + return { data: { total_count: 0, items: [] } }; + }); + + process.env.GH_AW_GROUP_REPORTS = "true"; + + const graphqlMock = vi.fn(async () => ({ repository: { issue: { subIssues: { totalCount: 0 } } } })); + + global.github = { + rest: { + search: { + issuesAndPullRequests: searchMock, + }, + issues: { + create: createIssueMock, + createComment: createCommentMock, + }, + pulls: { get: vi.fn() }, + }, + graphql: graphqlMock, + }; + + await main(); + + const parentCreateCall = createIssueMock.mock.calls.map(([call]) => call).find(call => call.title === "[aw] Failed runs"); + expect(parentCreateCall).toBeDefined(); + expect(parentCreateCall.body).toContain("previous parent issue #199"); + // Expired parent must not be reused: getSubIssueCount must not be queried + // for the expired parent #199, since the expiration check short-circuits first. + expect(graphqlMock).not.toHaveBeenCalledWith(expect.stringContaining("subIssues"), expect.objectContaining({ issueNumber: 199 })); + }); + it("escapes workflow IDs before searching for legacy XML marker matches", async () => { const createCommentMock = vi.fn(async () => ({ data: { id: 1001 } })); const createIssueMock = vi.fn(); diff --git a/docs/src/content/docs/reference/ephemerals.md b/docs/src/content/docs/reference/ephemerals.md index 1379b56f81c..ebeab9d3a7d 100644 --- a/docs/src/content/docs/reference/ephemerals.md +++ b/docs/src/content/docs/reference/ephemerals.md @@ -142,6 +142,8 @@ Customize the runner: `action_failure_issue_expires` sets expiration, in hours, for failure issues opened by the conclusion job, including grouped parent issues when `group-reports: true`. The default is `168` (7 days). +Explicitly setting `action_failure_issue_expires` in `aw.json` is treated as an opt-in: it causes `agentics-maintenance.yml` to be generated (if not already generated by another expiring safe output) so the scheduled `close-expired-entities` job can enforce it. If `action_failure_issue_expires` is left unset and no other workflow output requires scheduled maintenance, the implicit 168-hour default is not written into failure issues, since there would be no scheduled job to close them; failure issues are created without an expiration marker in that case. + If `.github/workflows/aw.json` is present but cannot be loaded, parsed, or validated, compilation keeps the default `168`-hour expiration and emits a warning that identifies the config path and fallback value. `disabled_jobs` lets you omit specific maintenance jobs from the generated workflow. Job IDs are case-insensitive, and `_` / `-` are treated equivalently. diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 8a5f3d9877e..4583a892e7f 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/github/gh-aw/pkg/constants" @@ -196,11 +197,18 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo runsOnValue := FormatRunsOn(configuredRunsOn, defaultRunsOn) // Scan workflows for expires fields and track the minimum expires value - hasExpires, minExpires, triggerReason := scanWorkflowsForExpires(workflowDataList) + hasExpires, minExpires, triggerReason := scanWorkflowsForExpires(workflowDataList, repoConfig) if !hasExpires { maintenanceLog.Print("No workflows use expires field, skipping maintenance workflow generation") + // No maintenance workflow means no scheduled close-expired-issues consumer. + // Since the implicit 168-hour action-failure default was not an opt-in + // (see scanWorkflowsForExpires), disable the runtime expiration marker in + // the already-compiled lock files so failure issues do not claim an + // expiration that nothing will enforce. + disableDefaultActionFailureExpiryMarkers(workflowDataList, workflowDir) + // Delete existing maintenance workflow file if it exists (no expires means no need for maintenance) maintenanceFile := filepath.Join(workflowDir, "agentics-maintenance.yml") if _, err := os.Stat(maintenanceFile); err == nil { @@ -341,6 +349,11 @@ func autoUpgradeCronFrom(cfg *RepoConfig) string { func handleMaintenanceDisabled(workflowDataList []*WorkflowData, workflowDir string) error { maintenanceLog.Print("Maintenance disabled via repo config, skipping generation") + // Explicit opt-out means no scheduled close-expired-issues consumer will + // exist regardless of any action_failure_issue_expires configuration, so + // disable the runtime expiration marker just like the no-recognized-source case. + disableDefaultActionFailureExpiryMarkers(workflowDataList, workflowDir) + // Warn if any workflow uses expires — those features rely on maintenance // and will silently become no-ops when it is disabled. for _, workflowData := range workflowDataList { @@ -395,7 +408,15 @@ func allCopilotWorkflowsUseOrgBilling(workflowDataList []*WorkflowData) bool { // scanWorkflowsForExpires checks all workflow data for expires fields and returns // whether any expires fields are set, the minimum expires value in hours, and the // first reason that triggered maintenance workflow generation. -func scanWorkflowsForExpires(workflowDataList []*WorkflowData) (bool, int, string) { +// +// repoConfig may be nil. When maintenance.action_failure_issue_expires is +// explicitly configured in aw.json, it is treated as an opt-in trigger for +// maintenance workflow generation (see IsActionFailureIssueExpiresExplicit). +// The implicit 168-hour default is intentionally excluded from this scan: +// since safe-outputs.report-failure-as-issue defaults to true, treating the +// implicit default as an always-on trigger would force agentics-maintenance.yml +// into essentially every repository with a gh-aw workflow. +func scanWorkflowsForExpires(workflowDataList []*WorkflowData, repoConfig *RepoConfig) (bool, int, string) { hasExpires := false minExpires := 0 // Track minimum expires value in hours triggerReason := "" @@ -465,5 +486,79 @@ func scanWorkflowsForExpires(workflowDataList []*WorkflowData) (bool, int, strin } } + // Check for an explicitly configured action-failure issue expiry. Unlike the + // implicit 168-hour default, an explicit value is an opt-in and should both + // trigger maintenance workflow generation and participate in the minimum + // expires calculation, but only when some workflow could actually create + // action-failure issues (report-failure-as-issue defaults to true). + if repoConfig.IsActionFailureIssueExpiresExplicit() && anyWorkflowMayReportFailureAsIssue(workflowDataList) { + hasExpires = true + expires := repoConfig.ActionFailureIssueExpiresHours() + setTriggerReason(fmt.Sprintf("maintenance.action_failure_issue_expires=%dh is explicitly configured in %s", expires, RepoConfigFileName)) + maintenanceLog.Printf("Repo config explicitly sets action_failure_issue_expires to %d hours", expires) + if minExpires == 0 || expires < minExpires { + minExpires = expires + } + } + return hasExpires, minExpires, triggerReason } + +// disableDefaultActionFailureExpiryMarkers rewrites the already-compiled lock +// files for workflowDataList so that the implicit 168-hour action-failure +// expiration marker is disabled (set to "0", meaning "no expiration"). This is +// called only when scanWorkflowsForExpires determined that no maintenance +// workflow will be generated, so the implicit default (which is not an +// opt-in) must not be advertised in failure issues since nothing would enforce +// it. Workflows that explicitly configured maintenance.action_failure_issue_expires +// are never affected by this function, because an explicit configuration +// always makes scanWorkflowsForExpires report hasExpires=true. +func disableDefaultActionFailureExpiryMarkers(workflowDataList []*WorkflowData, workflowDir string) { + defaultLine := fmt.Sprintf("GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: %q", strconv.Itoa(DefaultActionFailureIssueExpiresHours)) + disabledLine := `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"` + + for _, workflowData := range workflowDataList { + if workflowData == nil || workflowData.WorkflowID == "" { + continue + } + lockFile := filepath.Join(workflowDir, workflowData.WorkflowID+".lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + // Lock file may not exist (e.g. --no-emit compiles); nothing to patch. + continue + } + if !strings.Contains(string(content), defaultLine) { + continue + } + updated := strings.ReplaceAll(string(content), defaultLine, disabledLine) + if updated == string(content) { + continue + } + if err := os.WriteFile(lockFile, []byte(updated), 0o644); err != nil { + maintenanceLog.Printf("Warning: failed to disable action-failure expiry marker in %s: %v", lockFile, err) + continue + } + maintenanceLog.Printf("Disabled implicit action-failure expiry marker in %s (no maintenance workflow will enforce it)", lockFile) + } +} + +// anyWorkflowMayReportFailureAsIssue returns true unless every workflow in the +// list explicitly disables safe-outputs.report-failure-as-issue. The setting +// defaults to enabled (true) when unset, so an empty or all-nil-SafeOutputs +// list is treated as "may report" as well. +func anyWorkflowMayReportFailureAsIssue(workflowDataList []*WorkflowData) bool { + sawAny := false + for _, workflowData := range workflowDataList { + if workflowData == nil { + continue + } + sawAny = true + if workflowData.SafeOutputs == nil || workflowData.SafeOutputs.ReportFailureAsIssue == nil { + return true + } + if workflowData.SafeOutputs.ReportFailureAsIssue.String() != "false" { + return true + } + } + return !sawAny +} diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index e25e300bba0..afe00bd4aff 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -254,7 +254,7 @@ func TestScanWorkflowsForExpires_TriggerReason(t *testing.T) { Name: "no-safe-outputs", SafeOutputs: nil, }, - }) + }, nil) require.False(t, hasExpires) require.Equal(t, 0, minExpires) require.Empty(t, triggerReason) @@ -278,7 +278,7 @@ func TestScanWorkflowsForExpires_TriggerReason(t *testing.T) { }, }, }, - }) + }, nil) require.True(t, hasExpires) require.Equal(t, 24, minExpires) require.Contains(t, triggerReason, "first-trigger") @@ -305,7 +305,7 @@ func TestScanWorkflowsForExpires_TriggerReason(t *testing.T) { Name: "implicit-noop", SafeOutputs: safeOutputs, }, - }) + }, nil) require.False(t, hasExpires) require.Equal(t, 0, minExpires) require.Empty(t, triggerReason) @@ -329,12 +329,176 @@ func TestScanWorkflowsForExpires_TriggerReason(t *testing.T) { Name: "explicit-noop", SafeOutputs: safeOutputs, }, - }) + }, nil) require.True(t, hasExpires) require.Equal(t, defaultNoOpIssueExpirationHours, minExpires) require.Contains(t, triggerReason, "explicit-noop") require.Contains(t, triggerReason, "no-op issue reporting") }) + + t.Run("implicit action-failure default does not trigger maintenance", func(t *testing.T) { + hasExpires, minExpires, triggerReason := scanWorkflowsForExpires([]*WorkflowData{ + { + Name: "default-action-failure", + SafeOutputs: &SafeOutputsConfig{}, + }, + }, nil) + require.False(t, hasExpires, "implicit 168h action-failure default must not force maintenance generation") + require.Equal(t, 0, minExpires) + require.Empty(t, triggerReason) + }) + + t.Run("explicit action-failure expiry triggers maintenance", func(t *testing.T) { + repoConfig := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + ActionFailureIssueExpires: 72, + ActionFailureIssueExpiresExplicit: true, + }, + } + hasExpires, minExpires, triggerReason := scanWorkflowsForExpires([]*WorkflowData{ + { + Name: "explicit-action-failure", + SafeOutputs: &SafeOutputsConfig{}, + }, + }, repoConfig) + require.True(t, hasExpires) + require.Equal(t, 72, minExpires) + require.Contains(t, triggerReason, "action_failure_issue_expires=72h") + }) + + t.Run("explicit action-failure expiry coexists with shorter safe-output expiry", func(t *testing.T) { + repoConfig := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + ActionFailureIssueExpires: 72, + ActionFailureIssueExpiresExplicit: true, + }, + } + hasExpires, minExpires, triggerReason := scanWorkflowsForExpires([]*WorkflowData{ + { + Name: "shorter-issue-expiry", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 24, + }, + }, + }, + }, repoConfig) + require.True(t, hasExpires) + require.Equal(t, 24, minExpires, "shorter safe-output expiry should win the minimum calculation") + require.Contains(t, triggerReason, "shorter-issue-expiry") + }) + + t.Run("explicit action-failure expiry coexists with longer safe-output expiry", func(t *testing.T) { + repoConfig := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + ActionFailureIssueExpires: 12, + ActionFailureIssueExpiresExplicit: true, + }, + } + hasExpires, minExpires, _ := scanWorkflowsForExpires([]*WorkflowData{ + { + Name: "longer-issue-expiry", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 96, + }, + }, + }, + }, repoConfig) + require.True(t, hasExpires) + require.Equal(t, 12, minExpires, "explicit action-failure expiry should win the minimum calculation when shorter") + }) + + t.Run("explicit action-failure expiry with no workflows enabling report-as-issue does not trigger", func(t *testing.T) { + repoConfig := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + ActionFailureIssueExpires: 72, + ActionFailureIssueExpiresExplicit: true, + }, + } + disabled := TemplatableBool("false") + hasExpires, minExpires, triggerReason := scanWorkflowsForExpires([]*WorkflowData{ + { + Name: "no-failure-reporting", + SafeOutputs: &SafeOutputsConfig{ + ReportFailureAsIssue: &disabled, + }, + }, + }, repoConfig) + require.False(t, hasExpires) + require.Equal(t, 0, minExpires) + require.Empty(t, triggerReason) + }) +} + +func TestGenerateMaintenanceWorkflow_DisablesImplicitActionFailureExpiryMarker(t *testing.T) { + tmpDir := t.TempDir() + + // Simulate a workflow that was already compiled by CompileWorkflow with the + // implicit 168-hour action-failure expiry default (buildAgentFailureCoreVars + // always writes some value before the maintenance decision is known). + lockFile := filepath.Join(tmpDir, "sample.lock.yml") + lockContent := "name: Sample\njobs:\n conclusion:\n steps:\n - env:\n GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: \"168\"\n" + require.NoError(t, os.WriteFile(lockFile, []byte(lockContent), 0o644)) + + err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ + WorkflowDataList: []*WorkflowData{ + { + Name: "Sample", + WorkflowID: "sample", + SafeOutputs: &SafeOutputsConfig{}, + }, + }, + WorkflowDir: tmpDir, + Version: "dev", + ActionMode: ActionModeDev, + }) + require.NoError(t, err) + + // No maintenance workflow should be generated for the implicit default alone. + _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")) + require.True(t, os.IsNotExist(statErr), "agentics-maintenance.yml should not be generated for the implicit default") + + patched, err := os.ReadFile(lockFile) + require.NoError(t, err) + require.Contains(t, string(patched), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"`, "implicit default marker should be disabled when no maintenance workflow will enforce it") + require.NotContains(t, string(patched), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"`) +} + +func TestGenerateMaintenanceWorkflow_PreservesActionFailureExpiryMarkerWhenAnotherSourceTriggersMaintenance(t *testing.T) { + tmpDir := t.TempDir() + + lockFile := filepath.Join(tmpDir, "sample.lock.yml") + lockContent := "name: Sample\njobs:\n conclusion:\n steps:\n - env:\n GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: \"168\"\n" + require.NoError(t, os.WriteFile(lockFile, []byte(lockContent), 0o644)) + + err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ + WorkflowDataList: []*WorkflowData{ + { + Name: "Sample", + WorkflowID: "sample", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 48, + }, + }, + }, + }, + WorkflowDir: tmpDir, + Version: "dev", + ActionMode: ActionModeDev, + }) + require.NoError(t, err) + + // Maintenance workflow should be generated because of the create_issues expiry. + _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")) + require.NoError(t, statErr, "agentics-maintenance.yml should be generated when another expiry source exists") + + // The implicit action-failure marker should be preserved (not disabled), + // since the generic close-expired-issues sweeper can now enforce it. + preserved, err := os.ReadFile(lockFile) + require.NoError(t, err) + require.Contains(t, string(preserved), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"`) } func TestGenerateMaintenanceWorkflow_CreatesWorkflowDirRecursively(t *testing.T) { diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 4b54ab061f7..a35b7876c49 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -104,6 +104,15 @@ type MaintenanceConfig struct { // failure issues opened by the conclusion job. Defaults to 168 (7 days). ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` + // ActionFailureIssueExpiresExplicit records whether action_failure_issue_expires + // was explicitly present in aw.json, as opposed to falling back to the + // implicit 168-hour default. This distinction matters because the implicit + // default must not, by itself, force generation of agentics-maintenance.yml + // (see scanWorkflowsForExpires); only an explicit opt-in does. Populated by + // the JSON loader below, not by json.Unmarshal (the field is unexported from + // the schema on purpose). + ActionFailureIssueExpiresExplicit bool `json:"-"` + // LabelTriggers controls all label-triggered jobs (disable_agentic_workflow, // label_apply_safe_outputs, etc.). // The value is treated as an opt-in flag: only true enables the jobs. @@ -290,7 +299,15 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(raw.Maintenance, &mc); err != nil { return fmt.Errorf("invalid maintenance configuration: %w", err) } - repoConfigLog.Printf("Maintenance field parsed as object: runsOn=%v, issueExpires=%d", mc.RunsOn, mc.ActionFailureIssueExpires) + // Detect whether action_failure_issue_expires was explicitly present in the + // source JSON, distinct from falling back to the implicit 168-hour default. + var mcPresence map[string]json.RawMessage + if err := json.Unmarshal(raw.Maintenance, &mcPresence); err == nil { + if _, ok := mcPresence["action_failure_issue_expires"]; ok { + mc.ActionFailureIssueExpiresExplicit = true + } + } + repoConfigLog.Printf("Maintenance field parsed as object: runsOn=%v, issueExpires=%d, issueExpiresExplicit=%v", mc.RunsOn, mc.ActionFailureIssueExpires, mc.ActionFailureIssueExpiresExplicit) r.Maintenance = &mc return nil } @@ -445,6 +462,14 @@ func (r *RepoConfig) ActionFailureIssueExpiresHours() int { return DefaultActionFailureIssueExpiresHours } +// IsActionFailureIssueExpiresExplicit returns true when aw.json explicitly sets +// maintenance.action_failure_issue_expires, as opposed to relying on the +// implicit 168-hour default. Only an explicit value is treated as an opt-in +// trigger for generating agentics-maintenance.yml. +func (r *RepoConfig) IsActionFailureIssueExpiresExplicit() bool { + return r != nil && r.Maintenance != nil && r.Maintenance.ActionFailureIssueExpiresExplicit +} + // cronFieldRange describes the allowed numeric range for a cron field. type cronFieldRange struct { name string diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index b777cc85d9f..93dca593f45 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -104,6 +104,18 @@ func TestLoadRepoConfig_ActionFailureIssueExpires(t *testing.T) { require.NotNil(t, cfg.Maintenance, "maintenance config should be set") assert.Equal(t, 72, cfg.Maintenance.ActionFailureIssueExpires, "action_failure_issue_expires should be parsed from aw.json") assert.Equal(t, 72, cfg.ActionFailureIssueExpiresHours(), "accessor should return configured expiration") + assert.True(t, cfg.IsActionFailureIssueExpiresExplicit(), "explicit action_failure_issue_expires should be flagged as explicit") +} + +func TestLoadRepoConfig_ActionFailureIssueExpiresNotExplicitWhenUnset(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"runs_on": "ubuntu-latest"}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + require.NotNil(t, cfg.Maintenance, "maintenance config should be set") + assert.Equal(t, DefaultActionFailureIssueExpiresHours, cfg.ActionFailureIssueExpiresHours(), "accessor should fall back to default") + assert.False(t, cfg.IsActionFailureIssueExpiresExplicit(), "action_failure_issue_expires should not be flagged explicit when absent from aw.json") } func TestLoadRepoConfig_MaintenanceCompileConfig(t *testing.T) { From fbd021c3b53330a0a1a1df3a0f931d9e504c396d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:09:13 +0000 Subject: [PATCH 3/5] Address code review feedback on expiration marker fetch and test clarity Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/handle_agent_failure.cjs | 8 ++++++-- actions/setup/js/handle_agent_failure.test.cjs | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index 45ca06928cf..d34df9d9ee5 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -602,8 +602,12 @@ async function ensureParentIssue(previousParentNumber = null, ownerOverride, rep // Enforce the parent issue's own expiration marker: an expired parent // must not keep receiving new sub-issues, mirroring isReusableFailureIssue's // handling of individual per-run failure issues. - let existingBody = typeof existingIssue.body === "string" ? existingIssue.body : ""; - if (!existingBody) { + let existingBody; + if (typeof existingIssue.body === "string") { + existingBody = existingIssue.body; + } else { + // The search API response may omit or truncate the body field; fetch the + // full issue to reliably read the expiration marker. const issueResult = await github.rest.issues.get({ owner, repo, diff --git a/actions/setup/js/handle_agent_failure.test.cjs b/actions/setup/js/handle_agent_failure.test.cjs index 3e39dd7fcae..b206937c2de 100644 --- a/actions/setup/js/handle_agent_failure.test.cjs +++ b/actions/setup/js/handle_agent_failure.test.cjs @@ -752,6 +752,9 @@ describe("handle_agent_failure", () => { await main(); + // github.rest.issues.create is always invoked with a single options object + // (see github.rest.issues.create({...}) call sites in handle_agent_failure.cjs), + // so destructuring the first call argument yields the options object itself. const parentCreateCall = createIssueMock.mock.calls.map(([call]) => call).find(call => call.title === "[aw] Failed runs"); expect(parentCreateCall).toBeDefined(); expect(parentCreateCall.body).toContain("previous parent issue #199"); From 42eb6d93eb8528919be0bfc9f6ef967c7e9b384e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:23:54 +0000 Subject: [PATCH 4/5] docs(adr): add draft ADR-51425 for opt-in action-failure issue expiry semantics --- ...mantics-for-action-failure-issue-expiry.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/adr/51425-opt-in-semantics-for-action-failure-issue-expiry.md diff --git a/docs/adr/51425-opt-in-semantics-for-action-failure-issue-expiry.md b/docs/adr/51425-opt-in-semantics-for-action-failure-issue-expiry.md new file mode 100644 index 00000000000..85452ee087b --- /dev/null +++ b/docs/adr/51425-opt-in-semantics-for-action-failure-issue-expiry.md @@ -0,0 +1,45 @@ +# ADR-51425: Opt-In Semantics for Action-Failure Issue Expiry and Maintenance Generation + +**Date**: 2026-08-08 +**Status**: Draft +**Deciders**: pelikhan, Copilot SWE Agent + +--- + +### Context + +gh-aw always injects a `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS` value into compiled lock files (defaulting to 168 hours), but `scanWorkflowsForExpires` only considered explicit safe-output expiry when deciding whether to generate `agentics-maintenance.yml`. This meant that repositories relying solely on the implicit action-failure default would get failure issues tagged with an expiration marker that no scheduled `close-expired-issues` job would ever enforce. Grouped parent issues received the same unenforceable marker. Because `safe-outputs.report-failure-as-issue` defaults to `true`, naively treating the 168-hour default as an unconditional maintenance trigger would force `agentics-maintenance.yml` into essentially every repository with a gh-aw workflow — violating the opt-in posture established for no-op issue expiry in #37965. + +### Decision + +We will treat `maintenance.action_failure_issue_expires` in `aw.json` as a pure opt-in. Only an explicitly configured value (detected via a secondary `map[string]json.RawMessage` pass in `UnmarshalJSON` that Go's `omitempty` cannot provide) causes `scanWorkflowsForExpires` to count action-failure expiry as a maintenance trigger and include it in the minimum-expires calculation. When `scanWorkflowsForExpires` returns `hasExpires=false` (no recognized expiry source, including no explicit action-failure config), the compiler patches already-generated lock files to replace the implicit `"168"` marker with `"0"`. The runtime treats `"0"` as "expiration disabled" and omits the marker from failure issues. When another recognized expiry source does trigger maintenance, the implicit marker is preserved because the generic `close-expired-issues` sweeper will enforce it. Parent issue reuse additionally checks the existing parent's expiration marker before sub-issue count, mirroring existing per-run issue logic. + +### Alternatives Considered + +#### Alternative 1: Make the implicit 168-hour default an unconditional maintenance trigger + +Treat the implicit `action_failure_issue_expires` default the same as an explicit value: if any workflow could report failure as an issue, always generate `agentics-maintenance.yml`. This removes the producer/consumer gap without requiring users to touch `aw.json`. Rejected because `report-failure-as-issue` defaults to `true`, which would cause every repository with a gh-aw workflow to receive a scheduled maintenance workflow — exactly the behavior the existing opt-in posture for no-op issue expiry (#37965, #38627) was designed to prevent. + +#### Alternative 2: Remove the implicit 168-hour default; require explicit configuration for any expiry + +Eliminate the implicit default entirely and only write an expiry marker when `action_failure_issue_expires` is explicitly set. This is a simpler contract but is a breaking change: existing compiled lock files and any documentation referring to the 168-hour default would be invalidated. It would also break backwards compatibility with older lock files (which always contain a positive value). Rejected in favor of the current approach, which preserves the default for repositories that have another maintenance source. + +### Consequences + +#### Positive +- Failure issues no longer advertise expiration deadlines that no scheduled job will enforce, eliminating a silent correctness gap. +- Repositories that don't need scheduled maintenance are not forced to adopt it; the opt-in posture matches the precedent set for no-op issue expiry. +- Explicit `action_failure_issue_expires` values participate correctly in the minimum-expires calculation for the `close-expired-issues` cron schedule. +- Expired grouped parent issues are now detected and bypassed before sub-issue count is checked, preventing indefinite accumulation of sub-issues under an expired parent. + +#### Negative +- `UnmarshalJSON` for `RepoConfig` now performs two JSON unmarshal passes on the maintenance object (one typed, one into `map[string]json.RawMessage`) to distinguish explicit from absent fields — a non-obvious pattern that future maintainers may find surprising. +- The runtime meaning of `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS="0"` changes from "fall back to 168" to "expiration disabled." The change is backwards-safe because compiler-generated lock files previously never contained `"0"`, but the new semantics must be documented and kept in sync with the compiler's patching logic. + +#### Neutral +- Side-repository (`failure-issue-repo`) maintenance coverage is explicitly left as a follow-up; the current fix addresses only the primary-repository case. +- The `anyWorkflowMayReportFailureAsIssue` helper introduces a new scan over `workflowDataList` but is only called from within `scanWorkflowsForExpires`, which is already O(n) over the same list. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 9300e4d520d92415e20f2ea28a297c515d14851b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:08:59 +0000 Subject: [PATCH 5/5] fix: harden action-failure expiry follow-up edge cases Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/handle_agent_failure.cjs | 25 +++++--- .../setup/js/handle_agent_failure.test.cjs | 60 +++++++++++++++++++ pkg/workflow/maintenance_workflow.go | 21 +++---- pkg/workflow/maintenance_workflow_test.go | 46 ++++++++++++++ 4 files changed, 129 insertions(+), 23 deletions(-) diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index d34df9d9ee5..e70bf5be069 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -68,9 +68,11 @@ function getActionFailureIssueExpiresHours() { if (raw === "") { return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS; } - const parsed = Number.parseInt(raw, 10); - if (Number.isInteger(parsed) && parsed >= 0) { - return parsed; + if (raw === "0") { + return 0; + } + if (/^[1-9]\d*$/.test(raw)) { + return Number(raw); } return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS; } @@ -608,12 +610,17 @@ async function ensureParentIssue(previousParentNumber = null, ownerOverride, rep } else { // The search API response may omit or truncate the body field; fetch the // full issue to reliably read the expiration marker. - const issueResult = await github.rest.issues.get({ - owner, - repo, - issue_number: existingIssue.number, - }); - existingBody = issueResult.data.body || ""; + try { + const issueResult = await github.rest.issues.get({ + owner, + repo, + issue_number: existingIssue.number, + }); + existingBody = issueResult.data.body || ""; + } catch (error) { + core.warning(`Could not fetch parent issue #${existingIssue.number} body: ${getErrorMessage(error)}. Continuing without expiration marker check.`); + existingBody = ""; + } } const parentExpirationDate = extractExpirationDate(existingBody); diff --git a/actions/setup/js/handle_agent_failure.test.cjs b/actions/setup/js/handle_agent_failure.test.cjs index b206937c2de..99a35523946 100644 --- a/actions/setup/js/handle_agent_failure.test.cjs +++ b/actions/setup/js/handle_agent_failure.test.cjs @@ -83,6 +83,11 @@ describe("handle_agent_failure", () => { process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "invalid"; expect(getActionFailureIssueExpiresHours()).toBe(168); }); + + it("returns default for malformed values with numeric prefixes", () => { + process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "0invalid"; + expect(getActionFailureIssueExpiresHours()).toBe(168); + }); }); describe("buildFailureIssueTitle", () => { @@ -763,6 +768,61 @@ describe("handle_agent_failure", () => { expect(graphqlMock).not.toHaveBeenCalledWith(expect.stringContaining("subIssues"), expect.objectContaining({ issueNumber: 199 })); }); + it("does not abort grouped handling when fetching parent issue body fails", async () => { + const createCommentMock = vi.fn(); + const createIssueMock = vi.fn(async ({ title }) => ({ + data: { + number: title === "[aw] Failed runs" ? 300 : 301, + html_url: `https://github.com/owner/repo/issues/${title === "[aw] Failed runs" ? 300 : 301}`, + node_id: title === "[aw] Failed runs" ? "I_parent_new" : "I_child", + }, + })); + const searchMock = vi.fn(async ({ q }) => { + if (q.includes("is:pr")) { + return { data: { total_count: 0, items: [] } }; + } + if (q.includes('"[aw] Failed runs"')) { + return { + data: { + total_count: 1, + items: [{ number: 199, html_url: "https://github.com/owner/repo/issues/199", node_id: "I_parent_old", body: null }], + }, + }; + } + return { data: { total_count: 0, items: [] } }; + }); + const getIssueMock = vi.fn(async () => { + throw new Error("transient API failure"); + }); + const graphqlMock = vi.fn(async () => ({ repository: { issue: { subIssues: { totalCount: 1 } } } })); + + process.env.GH_AW_GROUP_REPORTS = "true"; + + global.github = { + rest: { + search: { + issuesAndPullRequests: searchMock, + }, + issues: { + get: getIssueMock, + create: createIssueMock, + createComment: createCommentMock, + }, + pulls: { get: vi.fn() }, + }, + graphql: graphqlMock, + }; + + await main(); + + expect(getIssueMock).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 199 })); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Could not fetch parent issue #199 body")); + const parentCreateCall = createIssueMock.mock.calls.map(([call]) => call).find(call => call.title === "[aw] Failed runs"); + expect(parentCreateCall).toBeUndefined(); + expect(createIssueMock).toHaveBeenCalledOnce(); + expect(createCommentMock).not.toHaveBeenCalled(); + }); + it("escapes workflow IDs before searching for legacy XML marker matches", async () => { const createCommentMock = vi.fn(async () => ({ data: { id: 1001 } })); const createIssueMock = vi.fn(); diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 4583a892e7f..99d5062e39f 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -5,7 +5,7 @@ import ( "fmt" "os" "path/filepath" - "strconv" + "regexp" "strings" "github.com/github/gh-aw/pkg/constants" @@ -16,6 +16,7 @@ import ( ) var maintenanceLog = logger.New("workflow:maintenance_workflow") +var actionFailureIssueExpiryLineRegex = regexp.MustCompile(`(?m)^(\s*GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS:\s*")[1-9]\d*(")\s*$`) // generateInstallCLISteps generates YAML steps to install or build the gh-aw CLI. // In dev mode: builds from source using Setup Go + Build gh-aw (./gh-aw binary available) @@ -514,9 +515,6 @@ func scanWorkflowsForExpires(workflowDataList []*WorkflowData, repoConfig *RepoC // are never affected by this function, because an explicit configuration // always makes scanWorkflowsForExpires report hasExpires=true. func disableDefaultActionFailureExpiryMarkers(workflowDataList []*WorkflowData, workflowDir string) { - defaultLine := fmt.Sprintf("GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: %q", strconv.Itoa(DefaultActionFailureIssueExpiresHours)) - disabledLine := `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"` - for _, workflowData := range workflowDataList { if workflowData == nil || workflowData.WorkflowID == "" { continue @@ -527,14 +525,11 @@ func disableDefaultActionFailureExpiryMarkers(workflowDataList []*WorkflowData, // Lock file may not exist (e.g. --no-emit compiles); nothing to patch. continue } - if !strings.Contains(string(content), defaultLine) { - continue - } - updated := strings.ReplaceAll(string(content), defaultLine, disabledLine) + updated := actionFailureIssueExpiryLineRegex.ReplaceAllString(string(content), `${1}0${2}`) if updated == string(content) { continue } - if err := os.WriteFile(lockFile, []byte(updated), 0o644); err != nil { + if err := os.WriteFile(lockFile, []byte(updated), constants.FilePermPublic); err != nil { maintenanceLog.Printf("Warning: failed to disable action-failure expiry marker in %s: %v", lockFile, err) continue } @@ -544,15 +539,13 @@ func disableDefaultActionFailureExpiryMarkers(workflowDataList []*WorkflowData, // anyWorkflowMayReportFailureAsIssue returns true unless every workflow in the // list explicitly disables safe-outputs.report-failure-as-issue. The setting -// defaults to enabled (true) when unset, so an empty or all-nil-SafeOutputs -// list is treated as "may report" as well. +// defaults to enabled (true) when unset, but an empty list cannot report +// failures and therefore returns false. func anyWorkflowMayReportFailureAsIssue(workflowDataList []*WorkflowData) bool { - sawAny := false for _, workflowData := range workflowDataList { if workflowData == nil { continue } - sawAny = true if workflowData.SafeOutputs == nil || workflowData.SafeOutputs.ReportFailureAsIssue == nil { return true } @@ -560,5 +553,5 @@ func anyWorkflowMayReportFailureAsIssue(workflowDataList []*WorkflowData) bool { return true } } - return !sawAny + return false } diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index afe00bd4aff..a4b32250da8 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -429,6 +429,19 @@ func TestScanWorkflowsForExpires_TriggerReason(t *testing.T) { require.Equal(t, 0, minExpires) require.Empty(t, triggerReason) }) + + t.Run("explicit action-failure expiry with no workflows does not trigger", func(t *testing.T) { + repoConfig := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + ActionFailureIssueExpires: 72, + ActionFailureIssueExpiresExplicit: true, + }, + } + hasExpires, minExpires, triggerReason := scanWorkflowsForExpires(nil, repoConfig) + require.False(t, hasExpires) + require.Equal(t, 0, minExpires) + require.Empty(t, triggerReason) + }) } func TestGenerateMaintenanceWorkflow_DisablesImplicitActionFailureExpiryMarker(t *testing.T) { @@ -501,6 +514,39 @@ func TestGenerateMaintenanceWorkflow_PreservesActionFailureExpiryMarkerWhenAnoth require.Contains(t, string(preserved), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"`) } +func TestGenerateMaintenanceWorkflow_DisablesImplicitActionFailureExpiryMarkerWhenMaintenanceDisabled(t *testing.T) { + tmpDir := t.TempDir() + + lockFile := filepath.Join(tmpDir, "sample.lock.yml") + lockContent := "name: Sample\njobs:\n conclusion:\n steps:\n - env:\n GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: \"168\"\n" + require.NoError(t, os.WriteFile(lockFile, []byte(lockContent), 0o644)) + + err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ + WorkflowDataList: []*WorkflowData{ + { + Name: "Sample", + WorkflowID: "sample", + SafeOutputs: &SafeOutputsConfig{}, + }, + }, + WorkflowDir: tmpDir, + RepoConfig: &RepoConfig{ + MaintenanceDisabled: true, + }, + Version: "dev", + ActionMode: ActionModeDev, + }) + require.NoError(t, err) + + _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")) + require.True(t, os.IsNotExist(statErr), "agentics-maintenance.yml should not be generated when maintenance is disabled") + + patched, err := os.ReadFile(lockFile) + require.NoError(t, err) + require.Contains(t, string(patched), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"`, "implicit default marker should be disabled when maintenance is disabled") + require.NotContains(t, string(patched), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"`) +} + func TestGenerateMaintenanceWorkflow_CreatesWorkflowDirRecursively(t *testing.T) { tmpDir := t.TempDir() workflowDir := filepath.Join(tmpDir, "nested", "workflows")