From ba26b50b07980b860d04d9a61507e090388da0d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:10:24 +0000 Subject: [PATCH 01/15] feat: add disable_agentic_workflow label-triggered job to maintenance workflow When an issue or PR is labeled with "agentic-workflows:disable": - A new maintenance job reads the body to find the workflow_id from XML comment markers (gh-aw-workflow-id) - Disables the corresponding agentic workflow via gh aw disable - Posts a comment confirming the action Changes: - pkg/workflow/maintenance_conditions.go: add buildLabeledDisableCondition() - pkg/workflow/maintenance_workflow_yaml.go: add issues/pull_request label triggers and disable_agentic_workflow job - actions/setup/js/disable_agentic_workflow.cjs: new JS implementation - actions/setup/js/disable_agentic_workflow.test.cjs: JS unit tests - pkg/workflow/maintenance_workflow_test.go: Go unit tests for new job - .github/workflows/agentics-maintenance.yml: regenerated Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9713fb7c-1206-4aed-9d68-08edcfbc4394 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 55 ++++++ actions/setup/js/disable_agentic_workflow.cjs | 167 ++++++++++++++++++ .../js/disable_agentic_workflow.test.cjs | 76 ++++++++ pkg/workflow/maintenance_conditions.go | 19 ++ pkg/workflow/maintenance_workflow_test.go | 109 +++++++++++- pkg/workflow/maintenance_workflow_yaml.go | 57 +++++- 6 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 actions/setup/js/disable_agentic_workflow.cjs create mode 100644 actions/setup/js/disable_agentic_workflow.test.cjs diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index b243a781cfe..84d1b6669e2 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -40,6 +40,10 @@ on: - main paths: - '.github/workflows/*.md' + issues: + types: [labeled] + pull_request: + types: [labeled] workflow_dispatch: inputs: operation: @@ -554,6 +558,57 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); await main(); + disable_agentic_workflow: + if: ${{ (!(github.event.repository.fork)) && (github.event_name == 'issues' || github.event_name == 'pull_request') && github.event.label.name == 'agentic-workflows:disable' }} + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Build gh-aw + run: make build + + - name: Disable agentic workflow + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_CMD_PREFIX: ./gh-aw + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); + await main(); + compile-workflows: if: ${{ (!(github.event.repository.fork)) && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} runs-on: ubuntu-slim diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs new file mode 100644 index 00000000000..42cbc21814f --- /dev/null +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -0,0 +1,167 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_NOT_FOUND } = require("./error_codes.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); + +const DISABLE_LABEL = "agentic-workflows:disable"; + +/** + * Extract the workflow_id from an issue or pull request body using XML comment markers. + * + * Looks for: + * 1. Standalone marker: + * 2. Combined marker: + * + * @param {string|null|undefined} body - Issue or PR body + * @returns {string|null} Workflow ID or null if not found + */ +function extractWorkflowId(body) { + if (!body) return null; + + // Try standalone marker: + const standaloneMatch = body.match(//); + if (standaloneMatch) { + return standaloneMatch[1].trim(); + } + + // Try combined marker: + // Match workflow_id: value followed by comma, space, or end of comment + const combinedMatch = body.match(/workflow_id:\s*([\w.-]+)(?:[,\s]|-->)/); + if (combinedMatch) { + return combinedMatch[1].trim(); + } + + return null; +} + +/** + * Disable an agentic workflow when the "agentic-workflows:disable" label is applied to an issue or PR. + * + * Reads the labeled issue/PR body to extract the workflow_id from XML comment markers, + * disables the corresponding agentic workflow using `gh aw disable`, and posts a comment + * confirming the action. + * + * @returns {Promise} + */ +async function main() { + const eventName = context.eventName; + if (eventName !== "issues" && eventName !== "pull_request") { + core.info(`Skipping: unexpected event type '${eventName}'`); + return; + } + + const { owner, repo } = resolveExecutionOwnerRepo(); + + // Get the item (issue or PR) from the payload + const item = context.payload.issue || context.payload.pull_request; + if (!item) { + core.warning("No issue or pull_request found in event payload"); + return; + } + + const itemNumber = item.number; + const labelName = context.payload.label?.name; + + if (labelName !== DISABLE_LABEL) { + core.info(`Skipping: label '${labelName}' is not '${DISABLE_LABEL}'`); + return; + } + + const itemType = eventName === "issues" ? "issue" : "pull request"; + core.info(`Processing ${itemType} #${itemNumber} labeled with '${labelName}'`); + + // Extract workflow ID from body XML comment markers + const body = item.body || ""; + const workflowId = extractWorkflowId(body); + + if (!workflowId) { + core.warning(`Could not find workflow ID in ${itemType} #${itemNumber} body. ` + `Expected a marker.`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: itemNumber, + body: + `> [!WARNING]\n` + + `> **Could not disable agentic workflow**\n>\n` + + `> No workflow ID marker was found in this ${itemType}'s body. ` + + `The \`${DISABLE_LABEL}\` label can only be used on issues and pull requests that were created by an agentic workflow ` + + `(they contain a \`\` marker).\n>\n` + + `> To disable a workflow manually, use:\n` + + `> \`\`\`\n` + + `> gh aw disable \n` + + `> \`\`\``, + }); + core.setFailed(`${ERR_NOT_FOUND}: No workflow ID marker found in ${itemType} #${itemNumber}`); + return; + } + + core.info(`Found workflow ID: ${workflowId}`); + + // Disable the workflow using gh aw disable + const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; + const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean); + + core.info(`Disabling agentic workflow '${workflowId}'...`); + let exitCode; + try { + exitCode = await exec.exec(bin, [...prefixArgs, "disable", workflowId], { + env: { ...process.env, GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "" }, + ignoreReturnCode: true, + }); + } catch (err) { + const msg = getErrorMessage(err); + core.error(`Failed to run disable command: ${msg}`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: itemNumber, + body: + `> [!WARNING]\n` + + `> **Failed to disable agentic workflow \`${workflowId}\`**\n>\n` + + `> The disable command encountered an error: ${msg}\n>\n` + + `> Please check the [workflow run logs](${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID || ""}) for details.`, + }); + core.setFailed(`Failed to disable workflow '${workflowId}': ${msg}`); + return; + } + + if (exitCode !== 0) { + const msg = `Command exited with code ${exitCode}`; + core.error(msg); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: itemNumber, + body: + `> [!WARNING]\n` + + `> **Failed to disable agentic workflow \`${workflowId}\`**\n>\n` + + `> The \`gh aw disable ${workflowId}\` command failed (exit code ${exitCode}).\n>\n` + + `> Please check the [workflow run logs](${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID || ""}) for details.`, + }); + core.setFailed(`gh aw disable '${workflowId}' failed with exit code ${exitCode}`); + return; + } + + core.info(`Successfully disabled workflow '${workflowId}'`); + + // Post a success comment on the issue/PR + await github.rest.issues.createComment({ + owner, + repo, + issue_number: itemNumber, + body: + `The agentic workflow \`${workflowId}\` has been disabled.\n\n` + + `To re-enable it, use:\n` + + `\`\`\`\n` + + `gh aw enable ${workflowId}\n` + + `\`\`\`\n\n` + + `Or trigger the maintenance workflow with the \`enable\` operation.\n\n` + + ``, + }); + + core.info(`Posted disable confirmation comment on ${itemType} #${itemNumber}`); +} + +module.exports = { main, extractWorkflowId }; diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs new file mode 100644 index 00000000000..0229affc83a --- /dev/null +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -0,0 +1,76 @@ +// @ts-check + +import { describe, it, expect } from "vitest"; +import { extractWorkflowId } from "./disable_agentic_workflow.cjs"; + +describe("extractWorkflowId", () => { + it("returns null for null body", () => { + expect(extractWorkflowId(null)).toBeNull(); + }); + + it("returns null for undefined body", () => { + expect(extractWorkflowId(undefined)).toBeNull(); + }); + + it("returns null for empty body", () => { + expect(extractWorkflowId("")).toBeNull(); + }); + + it("returns null when no marker is present", () => { + expect(extractWorkflowId("This is a normal issue body with no markers.")).toBeNull(); + }); + + it("extracts workflow ID from standalone marker", () => { + const body = "Some issue text\n\n"; + expect(extractWorkflowId(body)).toBe("my-workflow"); + }); + + it("extracts workflow ID from standalone marker with extra whitespace", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("code-review"); + }); + + it("extracts workflow ID from combined agentic-workflow marker (comma-separated)", () => { + const body = "Issue body\n" + ""; + expect(extractWorkflowId(body)).toBe("ci-doctor"); + }); + + it("extracts workflow ID from combined marker when workflow_id is last before closing -->", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("auto-fix"); + }); + + it("prefers standalone marker over combined marker when both are present", () => { + const body = "\n" + ""; + expect(extractWorkflowId(body)).toBe("standalone-workflow"); + }); + + it("handles workflow IDs with dots", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("my.workflow.v2"); + }); + + it("handles workflow IDs with underscores", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("code_review_bot"); + }); + + it("extracts from body with substantial content before marker", () => { + const body = [ + "## Issue Title", + "", + "This is a long description of the issue created by an agentic workflow.", + "It contains multiple paragraphs.", + "", + "### Details", + "Some details here.", + "", + "> Closed by [My Workflow](https://github.com/owner/repo/actions/runs/123)", + "", + "", + "", + "", + ].join("\n"); + expect(extractWorkflowId(body)).toBe("expired-issue-workflow"); + }); +}); diff --git a/pkg/workflow/maintenance_conditions.go b/pkg/workflow/maintenance_conditions.go index 176a8a80902..35fa74be1f0 100644 --- a/pkg/workflow/maintenance_conditions.go +++ b/pkg/workflow/maintenance_conditions.go @@ -102,6 +102,25 @@ func buildDispatchOperationCondition(operation string) ConditionNode { ) } +// buildLabeledDisableCondition creates a condition for the disable_agentic_workflow job +// that triggers when an issue or pull request is labeled with "agentic-workflows:disable". +// Condition: !fork && (event_name == 'issues' || event_name == 'pull_request') && event.label.name == 'agentic-workflows:disable' +func buildLabeledDisableCondition() ConditionNode { + return BuildAnd( + buildNotForkCondition(), + BuildAnd( + BuildOr( + BuildEventTypeEquals("issues"), + BuildEventTypeEquals("pull_request"), + ), + BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral("agentic-workflows:disable"), + ), + ), + ) +} + // buildRunOperationCondition creates the condition for the unified run_operation // job that handles all dispatch/call operations except the ones with dedicated jobs. // Condition: (dispatch || call) && operation != ” && operation != each excluded && !fork. diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 5b88c2186b4..c09bb93082a 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -538,6 +538,107 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } +func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 48, + }, + }, + }, + } + + tmpDir := t.TempDir() + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + const jobSectionSearchRange = 2000 + + // Verify the label triggers are present in the on: section + if !strings.Contains(yaml, " issues:\n types: [labeled]") { + t.Error("Maintenance workflow should include issues: types: [labeled] trigger") + } + if !strings.Contains(yaml, " pull_request:\n types: [labeled]") { + t.Error("Maintenance workflow should include pull_request: types: [labeled] trigger") + } + + // Verify the disable_agentic_workflow job exists + disableJobIdx := strings.Index(yaml, "\n disable_agentic_workflow:") + if disableJobIdx == -1 { + t.Fatal("Job disable_agentic_workflow not found in generated workflow") + } + disableJobSection := yaml[disableJobIdx : disableJobIdx+jobSectionSearchRange] + + // Verify the condition triggers on issues and pull_request label events + if !strings.Contains(disableJobSection, "github.event_name == 'issues' || github.event_name == 'pull_request'") { + t.Errorf("disable_agentic_workflow job should trigger on issues and pull_request events in:\n%s", disableJobSection) + } + if !strings.Contains(disableJobSection, "github.event.label.name == 'agentic-workflows:disable'") { + t.Errorf("disable_agentic_workflow job should check for agentic-workflows:disable label in:\n%s", disableJobSection) + } + if !strings.Contains(disableJobSection, "github.event.repository.fork") { + t.Errorf("disable_agentic_workflow job should exclude forks in:\n%s", disableJobSection) + } + + // Verify required permissions + if !strings.Contains(disableJobSection, "actions: write") { + t.Errorf("disable_agentic_workflow job should have actions: write permission in:\n%s", disableJobSection) + } + if !strings.Contains(disableJobSection, "issues: write") { + t.Errorf("disable_agentic_workflow job should have issues: write permission in:\n%s", disableJobSection) + } + if !strings.Contains(disableJobSection, "pull-requests: write") { + t.Errorf("disable_agentic_workflow job should have pull-requests: write permission in:\n%s", disableJobSection) + } + + // Verify the job uses disable_agentic_workflow.cjs + if !strings.Contains(disableJobSection, "disable_agentic_workflow.cjs") { + t.Errorf("disable_agentic_workflow job should use disable_agentic_workflow.cjs script in:\n%s", disableJobSection) + } + + // Verify the job includes the CLI installation and permission check steps + if !strings.Contains(disableJobSection, "check_team_member.cjs") { + t.Errorf("disable_agentic_workflow job should check permissions using check_team_member.cjs in:\n%s", disableJobSection) + } +} + +func TestBuildLabeledDisableCondition(t *testing.T) { + condition := buildLabeledDisableCondition() + rendered := RenderCondition(condition) + + // Should include both issues and pull_request event names + if !strings.Contains(rendered, "github.event_name == 'issues'") { + t.Errorf("Condition should include issues event, got: %s", rendered) + } + if !strings.Contains(rendered, "github.event_name == 'pull_request'") { + t.Errorf("Condition should include pull_request event, got: %s", rendered) + } + + // Should check the label name + if !strings.Contains(rendered, "github.event.label.name == 'agentic-workflows:disable'") { + t.Errorf("Condition should check for agentic-workflows:disable label, got: %s", rendered) + } + + // Should exclude forks + if !strings.Contains(rendered, "github.event.repository.fork") { + t.Errorf("Condition should exclude forks, got: %s", rendered) + } + + // Should not include workflow_dispatch or schedule-related conditions + if strings.Contains(rendered, "workflow_dispatch") || strings.Contains(rendered, "workflow_call") { + t.Errorf("Condition should not reference workflow_dispatch or workflow_call, got: %s", rendered) + } +} + func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { const jobSectionSearchRange = 500 @@ -892,12 +993,12 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Fatalf("Expected maintenance workflow to be generated: %v", err) } yaml := string(content) - // run_operation, create_labels, activity_report, validate_workflows, and compile_workflows should use the same setup-go version - // (all use getActionPin, not hardcoded pins). Exactly 5 occurrences expected. + // run_operation, create_labels, activity_report, validate_workflows, disable_agentic_workflow, and compile_workflows should use the same setup-go version + // (all use getActionPin, not hardcoded pins). Exactly 6 occurrences expected. setupGoPin := getActionPin("actions/setup-go") occurrences := strings.Count(yaml, setupGoPin) - if occurrences != 5 { - t.Errorf("Expected exactly 5 occurrences of pinned setup-go ref %q (run_operation + create_labels + activity_report + validate_workflows + compile_workflows), got %d in:\n%s", + if occurrences != 6 { + t.Errorf("Expected exactly 6 occurrences of pinned setup-go ref %q (run_operation + create_labels + activity_report + validate_workflows + disable_agentic_workflow + compile_workflows), got %d in:\n%s", setupGoPin, occurrences, yaml) } }) diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 57eaf5d996c..6ca938f5a3a 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -57,7 +57,11 @@ on: `) } - yaml.WriteString(` workflow_dispatch: + yaml.WriteString(` issues: + types: [labeled] + pull_request: + types: [labeled] + workflow_dispatch: inputs: operation: description: 'Optional maintenance operation to run' @@ -614,6 +618,57 @@ jobs: await main(); `) + // Add disable_agentic_workflow job triggered by label "agentic-workflows:disable" on issues or PRs. + // This job reads the body of the labeled issue/PR to extract the workflow_id from XML comment + // markers, disables the corresponding agentic workflow, and posts a confirmation comment. + disableLabelCondition := buildLabeledDisableCondition() + yaml.WriteString(` + disable_agentic_workflow: + if: ${{ ` + RenderCondition(disableLabelCondition) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout repository + uses: ` + getActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + +`) + + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Disable agentic workflow + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); + await main(); +`) + // Add compile-workflows and zizmor-scan jobs only in dev mode // These jobs are specific to the gh-aw repository and require go.mod, make build, etc. // User repositories won't have these dependencies, so we skip them in release mode From f932e1bfb07d69c8b58309f052af8440ef539bdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:16:00 +0000 Subject: [PATCH 02/15] fix: address code review feedback - improve regex security and env isolation - Restrict combined-marker regex to gh-aw-agentic-workflow comment blocks to prevent matching workflow_id: in user content - Add isValidWorkflowId() to validate extracted IDs against path traversal and shell-unsafe characters - Pass only required env vars (not ...process.env spread) to exec subprocess - Add test cases for security validation and outside-comment non-match Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9713fb7c-1206-4aed-9d68-08edcfbc4394 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/disable_agentic_workflow.cjs | 43 +++++++++++++++---- .../js/disable_agentic_workflow.test.cjs | 25 ++++++++--- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index 42cbc21814f..537e013904a 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -7,6 +7,20 @@ const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); const DISABLE_LABEL = "agentic-workflows:disable"; +/** + * Validate that an extracted workflow ID has a safe, expected format. + * Workflow IDs are file basenames (without .md) and must not contain + * path traversal sequences or other shell-unsafe characters. + * + * @param {string} id - Candidate workflow ID + * @returns {boolean} True if the ID is safe to use as a CLI argument + */ +function isValidWorkflowId(id) { + // Allow alphanumeric characters, hyphens, underscores, and dots. + // Reject anything else, as well as path traversal sequences like "..". + return id.length > 0 && id.length <= 100 && /^[\w.-]+$/.test(id) && !id.includes(".."); +} + /** * Extract the workflow_id from an issue or pull request body using XML comment markers. * @@ -14,8 +28,11 @@ const DISABLE_LABEL = "agentic-workflows:disable"; * 1. Standalone marker: * 2. Combined marker: * + * The combined marker is only searched within actual HTML comment blocks to prevent + * unintended matches in user-provided content. + * * @param {string|null|undefined} body - Issue or PR body - * @returns {string|null} Workflow ID or null if not found + * @returns {string|null} Workflow ID or null if not found or invalid */ function extractWorkflowId(body) { if (!body) return null; @@ -23,14 +40,16 @@ function extractWorkflowId(body) { // Try standalone marker: const standaloneMatch = body.match(//); if (standaloneMatch) { - return standaloneMatch[1].trim(); + const id = standaloneMatch[1].trim(); + return isValidWorkflowId(id) ? id : null; } - // Try combined marker: - // Match workflow_id: value followed by comma, space, or end of comment - const combinedMatch = body.match(/workflow_id:\s*([\w.-]+)(?:[,\s]|-->)/); - if (combinedMatch) { - return combinedMatch[1].trim(); + // Try combined marker, but only within HTML comment blocks that contain + // gh-aw-agentic-workflow: to avoid matching user content. + const commentMatch = body.match(/", () => { - const body = ""; + const body = ""; expect(extractWorkflowId(body)).toBe("auto-fix"); }); it("prefers standalone marker over combined marker when both are present", () => { - const body = "\n" + ""; + const body = "\n" + ""; expect(extractWorkflowId(body)).toBe("standalone-workflow"); }); @@ -60,10 +60,6 @@ describe("extractWorkflowId", () => { "## Issue Title", "", "This is a long description of the issue created by an agentic workflow.", - "It contains multiple paragraphs.", - "", - "### Details", - "Some details here.", "", "> Closed by [My Workflow](https://github.com/owner/repo/actions/runs/123)", "", @@ -73,4 +69,21 @@ describe("extractWorkflowId", () => { ].join("\n"); expect(extractWorkflowId(body)).toBe("expired-issue-workflow"); }); + + it("returns null for workflow_id outside of an XML comment block", () => { + // workflow_id: appearing outside a gh-aw-agentic-workflow comment should NOT be extracted + const body = "The workflow_id: my-injected-id is mentioned in user text."; + expect(extractWorkflowId(body)).toBeNull(); + }); + + it("returns null for workflow ID with path traversal attempt", () => { + const body = ""; + expect(extractWorkflowId(body)).toBeNull(); + }); + + it("returns null for workflow ID with shell-special characters", () => { + // The regex won't match ';' since it requires [\w.-]+ followed by whitespace/--> + const body = ""; + expect(extractWorkflowId(body)).toBeNull(); + }); }); From 574f10fe553576c50b18becd942dc002bda88b4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:02:24 +0000 Subject: [PATCH 03/15] fix: contents read-only, create disable label, remove label after success - Fix disable_agentic_workflow job: contents: write -> contents: read (only checkout is needed, no repo writes) - create_labels.cjs: always create the agentic-workflows:disable label with a fixed purple color (8250df) via BUILTIN_LABELS constant - disable_agentic_workflow.cjs: remove the agentic-workflows:disable label from the issue/PR after successfully disabling + commenting Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a4a225e4-d4e6-4415-80af-4dc10b63f77a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 2 +- actions/setup/js/create_labels.cjs | 35 +++- actions/setup/js/create_labels.test.cjs | 72 +++++++- actions/setup/js/disable_agentic_workflow.cjs | 16 +- .../js/disable_agentic_workflow.test.cjs | 159 +++++++++++++++++- pkg/workflow/maintenance_workflow_test.go | 6 + pkg/workflow/maintenance_workflow_yaml.go | 2 +- 7 files changed, 278 insertions(+), 14 deletions(-) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 84d1b6669e2..f6b5679151b 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -563,7 +563,7 @@ jobs: runs-on: ubuntu-slim permissions: actions: write - contents: write + contents: read issues: write pull-requests: write steps: diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index c2de0842f20..8ad34b70078 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -5,6 +5,26 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); +/** + * Fixed colors for specific well-known labels. + * These labels always get the specified hex color instead of a deterministic pastel. + */ +const FIXED_LABEL_COLORS = { + "agentic-workflows:disable": "8250df", // GitHub purple +}; + +/** + * Built-in labels that are always created regardless of workflow configuration. + * Each entry is { name: string, color: string, description: string }. + */ +const BUILTIN_LABELS = [ + { + name: "agentic-workflows:disable", + color: FIXED_LABEL_COLORS["agentic-workflows:disable"], + description: "Disable the agentic workflow that created this issue or pull request", + }, +]; + /** * Generate a deterministic pastel hex color string from a label name. * Produces colors in the pastel range (128–191 per channel) for readability. @@ -86,6 +106,11 @@ async function main() { ) ); + // Always include built-in labels + for (const builtin of BUILTIN_LABELS) { + allLabels.add(builtin.name); + } + if (allLabels.size === 0) { core.info("No labels found in safe-outputs configurations — nothing to create"); return; @@ -121,13 +146,17 @@ async function main() { core.info(`ℹ️ Label already exists: ${labelName}`); skipped++; } else { + // Use fixed color and description for known built-in labels; fall back to deterministic pastel + const builtin = BUILTIN_LABELS.find(b => b.name === labelName); + const color = builtin ? builtin.color : deterministicLabelColor(labelName); + const description = builtin ? builtin.description : ""; try { await github.rest.issues.createLabel({ owner, repo, name: labelName, - color: deterministicLabelColor(labelName), - description: "", + color, + description, }); core.info(`✅ Created label: ${labelName}`); created++; @@ -146,4 +175,4 @@ async function main() { core.info(`Done: ${created} label(s) created, ${skipped} already existed`); } -module.exports = { main, deterministicLabelColor }; +module.exports = { main, deterministicLabelColor, BUILTIN_LABELS, FIXED_LABEL_COLORS }; diff --git a/actions/setup/js/create_labels.test.cjs b/actions/setup/js/create_labels.test.cjs index 3abd040d3bd..6b1a13f8a37 100644 --- a/actions/setup/js/create_labels.test.cjs +++ b/actions/setup/js/create_labels.test.cjs @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { main, deterministicLabelColor } = req("./create_labels.cjs"); +const { main, deterministicLabelColor, BUILTIN_LABELS } = req("./create_labels.cjs"); // ─── global mocks ──────────────────────────────────────────────────────────── @@ -91,15 +91,15 @@ describe("main", () => { stderr: "", }); - // Default: repo has one existing label - mockGithub.paginate.mockResolvedValue([{ name: "bug" }]); + // Default: repo has the builtin labels already plus "bug" + mockGithub.paginate.mockResolvedValue([{ name: "bug" }, ...BUILTIN_LABELS.map(b => ({ name: b.name }))]); mockGithub.rest.issues.createLabel.mockResolvedValue({}); }); it("creates labels that are missing from the repository", async () => { await main(); - expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledTimes(2); + // enhancement and docs are missing from the repo; builtin labels are already present const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); expect(names).toContain("enhancement"); expect(names).toContain("docs"); @@ -137,17 +137,77 @@ describe("main", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already existed")); }); - it("does nothing when no labels are found", async () => { + it("always creates the built-in agentic-workflows:disable label", async () => { + // Repo has no labels at all + mockGithub.paginate.mockResolvedValue([]); + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: [] }]), + stderr: "", + }); + + await main(); + + const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + expect(names).toContain("agentic-workflows:disable"); + }); + + it("creates the agentic-workflows:disable label with the fixed purple color", async () => { + mockGithub.paginate.mockResolvedValue([]); + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: [] }]), + stderr: "", + }); + + await main(); + + const call = mockGithub.rest.issues.createLabel.mock.calls.find(c => c[0].name === "agentic-workflows:disable"); + expect(call).toBeDefined(); + expect(call[0].color).toBe("8250df"); + }); + + it("skips creating agentic-workflows:disable when it already exists", async () => { + mockGithub.paginate.mockResolvedValue([{ name: "agentic-workflows:disable" }]); + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: [] }]), + stderr: "", + }); + + await main(); + + const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + expect(names).not.toContain("agentic-workflows:disable"); + }); + + it("still processes builtin labels even when no workflow labels are found", async () => { + mockGithub.paginate.mockResolvedValue([]); + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: [] }, {}]), + stderr: "", + }); + + await main(); + + // builtin labels are always created even with no workflow labels + const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + expect(names).toContain("agentic-workflows:disable"); + }); + + it("does nothing when no labels are found and all builtins already exist", async () => { mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: JSON.stringify([{ labels: [] }, {}]), stderr: "", }); + // All builtin labels already exist + mockGithub.paginate.mockResolvedValue(BUILTIN_LABELS.map(b => ({ name: b.name }))); await main(); expect(mockGithub.rest.issues.createLabel).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No labels found")); }); it("ignores non-string or empty label values", async () => { diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index 537e013904a..626d387a645 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -189,6 +189,20 @@ async function main() { }); core.info(`Posted disable confirmation comment on ${itemType} #${itemNumber}`); + + // Remove the disable label now that the action is complete + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: itemNumber, + name: DISABLE_LABEL, + }); + core.info(`Removed label '${DISABLE_LABEL}' from ${itemType} #${itemNumber}`); + } catch (err) { + // Non-fatal: the disable already succeeded, just log a warning + core.warning(`Failed to remove label '${DISABLE_LABEL}': ${getErrorMessage(err)}`); + } } -module.exports = { main, extractWorkflowId }; +module.exports = { main, extractWorkflowId, isValidWorkflowId }; diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs index 81986adc6f4..c6e0c19f2f2 100644 --- a/actions/setup/js/disable_agentic_workflow.test.cjs +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -1,7 +1,162 @@ // @ts-check -import { describe, it, expect } from "vitest"; -import { extractWorkflowId } from "./disable_agentic_workflow.cjs"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { extractWorkflowId, main } = req("./disable_agentic_workflow.cjs"); + +// ─── global mocks ──────────────────────────────────────────────────────────── + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), +}; + +const mockExec = { + exec: vi.fn(), +}; + +const mockGithub = { + rest: { + issues: { + createComment: vi.fn(), + removeLabel: vi.fn(), + }, + }, +}; + +const mockContext = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 42, body: "" }, + label: { name: "agentic-workflows:disable" }, + }, +}; + +global.core = mockCore; +global.exec = mockExec; +global.github = mockGithub; +global.context = mockContext; + +describe("main", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.GH_AW_CMD_PREFIX = "./gh-aw"; + process.env.GH_TOKEN = "fake-token"; + process.env.GITHUB_TOKEN = "fake-token"; + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; + process.env.GITHUB_SERVER_URL = "https://github.com"; + process.env.GITHUB_RUN_ID = "999"; + + // Default: disable command succeeds + mockExec.exec.mockResolvedValue(0); + mockGithub.rest.issues.createComment.mockResolvedValue({}); + mockGithub.rest.issues.removeLabel.mockResolvedValue({}); + + // Restore default context (issue event) + global.context = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 42, body: "" }, + label: { name: "agentic-workflows:disable" }, + }, + }; + }); + + it("disables the workflow and posts a success comment", async () => { + await main(); + + expect(mockExec.exec).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(["disable", "my-workflow"]), expect.objectContaining({ ignoreReturnCode: true })); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + issue_number: 42, + body: expect.stringContaining("my-workflow"), + }) + ); + }); + + it("removes the label after successful disable and comment", async () => { + await main(); + + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + issue_number: 42, + name: "agentic-workflows:disable", + }) + ); + }); + + it("removes label after success on pull_request events too", async () => { + global.context = { + eventName: "pull_request", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + pull_request: { number: 7, body: "" }, + label: { name: "agentic-workflows:disable" }, + }, + }; + + await main(); + + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 7, name: "agentic-workflows:disable" })); + }); + + it("does not remove label when no workflow ID marker is found", async () => { + global.context = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 5, body: "No marker here." }, + label: { name: "agentic-workflows:disable" }, + }, + }; + + await main(); + + expect(mockGithub.rest.issues.removeLabel).not.toHaveBeenCalled(); + }); + + it("does not remove label when the disable command fails", async () => { + mockExec.exec.mockResolvedValue(1); // non-zero exit + + await main(); + + expect(mockGithub.rest.issues.removeLabel).not.toHaveBeenCalled(); + }); + + it("logs a warning when label removal fails but does not fail the step", async () => { + mockGithub.rest.issues.removeLabel.mockRejectedValue(new Error("Not Found")); + + await main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to remove label")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("calls setFailed when no workflow ID is found in body", async () => { + global.context = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 3, body: "Plain body with no markers." }, + label: { name: "agentic-workflows:disable" }, + }, + }; + + await main(); + + expect(mockCore.setFailed).toHaveBeenCalled(); + }); +}); describe("extractWorkflowId", () => { it("returns null for null body", () => { diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index c09bb93082a..e19e33a5347 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -593,6 +593,12 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { if !strings.Contains(disableJobSection, "actions: write") { t.Errorf("disable_agentic_workflow job should have actions: write permission in:\n%s", disableJobSection) } + if !strings.Contains(disableJobSection, "contents: read") { + t.Errorf("disable_agentic_workflow job should have contents: read permission in:\n%s", disableJobSection) + } + if strings.Contains(disableJobSection, "contents: write") { + t.Errorf("disable_agentic_workflow job must NOT have contents: write (only read is needed) in:\n%s", disableJobSection) + } if !strings.Contains(disableJobSection, "issues: write") { t.Errorf("disable_agentic_workflow job should have issues: write permission in:\n%s", disableJobSection) } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 6ca938f5a3a..576444e1ccf 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -628,7 +628,7 @@ jobs: runs-on: ` + runsOnValue + ` permissions: actions: write - contents: write + contents: read issues: write pull-requests: write steps: From 3dc6fe8cb25232e378b06df9f0c881e5c8cd82cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:15:41 +0000 Subject: [PATCH 04/15] feat: add disable_label_trigger config field to aw.json maintenance Add a boolean field disable_label_trigger to the maintenance configuration object in aw.json. When set to true, the disable_agentic_workflow label-triggered job (and its associated issues/pull_request label event triggers) are omitted from the generated agentics-maintenance.yml. Default (false / omitted) keeps the feature enabled. - repo_config.go: add DisableLabelTrigger bool to MaintenanceConfig - repo_config_schema.json: add disable_label_trigger property - maintenance_workflow.go: read DisableLabelTrigger from config and pass it to buildMaintenanceWorkflowYAML - maintenance_workflow_yaml.go: conditionally emit label triggers and disable_agentic_workflow job based on new parameter - Tests: new cases in repo_config_test.go and maintenance_workflow_test.go Agent-Logs-Url: https://github.com/github/gh-aw/sessions/db22da91-8ef4-46cb-8b90-8e94d971c8f8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/repo_config_schema.json | 4 ++ pkg/workflow/maintenance_workflow.go | 4 +- pkg/workflow/maintenance_workflow_test.go | 76 +++++++++++++++++++++- pkg/workflow/maintenance_workflow_yaml.go | 23 +++++-- pkg/workflow/repo_config.go | 5 ++ pkg/workflow/repo_config_test.go | 21 ++++++ 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index b457ce56010..b14fbac95f4 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -51,6 +51,10 @@ "type": "integer", "minimum": 1, "examples": [24, 72, 168] + }, + "disable_label_trigger": { + "description": "Set to true to disable the label-triggered disable_agentic_workflow job. When absent or false (default), the job is included in the maintenance workflow.", + "type": "boolean" } } } diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 7e64b7d9074..dca4ff8694a 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -125,8 +125,10 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s // Determine the runs-on value to use for all maintenance jobs. const defaultRunsOn = "ubuntu-slim" var configuredRunsOn RunsOnValue + var disableLabelTrigger bool if repoConfig != nil && repoConfig.Maintenance != nil { configuredRunsOn = repoConfig.Maintenance.RunsOn + disableLabelTrigger = repoConfig.Maintenance.DisableLabelTrigger } runsOnValue := FormatRunsOn(configuredRunsOn, defaultRunsOn) @@ -176,7 +178,7 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s defaultBranch := FetchDefaultBranch(repoSlug) // Generate the YAML content for the maintenance workflow - content := buildMaintenanceWorkflowYAML(cronSchedule, scheduleDesc, minExpiresDays, runsOnValue, actionMode, version, actionTag, resolver, configuredRunsOn, defaultBranch) + content := buildMaintenanceWorkflowYAML(cronSchedule, scheduleDesc, minExpiresDays, runsOnValue, actionMode, version, actionTag, resolver, configuredRunsOn, defaultBranch, disableLabelTrigger) // Write the maintenance workflow file maintenanceFile := filepath.Join(workflowDir, "agentics-maintenance.yml") diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index e19e33a5347..d59ec8341b8 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -645,6 +645,80 @@ func TestBuildLabeledDisableCondition(t *testing.T) { } } +func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Disabled(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{DisableLabelTrigger: true}, + } + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + // Label-event triggers should be absent + if strings.Contains(yaml, " issues:\n types: [labeled]") { + t.Error("When disable_label_trigger is true the issues labeled trigger should not be present") + } + if strings.Contains(yaml, " pull_request:\n types: [labeled]") { + t.Error("When disable_label_trigger is true the pull_request labeled trigger should not be present") + } + + // The disable_agentic_workflow job should be absent + if strings.Contains(yaml, "disable_agentic_workflow:") { + t.Error("When disable_label_trigger is true the disable_agentic_workflow job should not be present") + } +} + +func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Default(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + // Default: DisableLabelTrigger is false (omitted) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + // Label-event triggers should be present by default + if !strings.Contains(yaml, " issues:\n types: [labeled]") { + t.Error("By default (no config) the issues labeled trigger should be present") + } + if !strings.Contains(yaml, " pull_request:\n types: [labeled]") { + t.Error("By default (no config) the pull_request labeled trigger should be present") + } + + // The disable_agentic_workflow job should be present by default + if !strings.Contains(yaml, "disable_agentic_workflow:") { + t.Error("By default (no config) the disable_agentic_workflow job should be present") + } +} + func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { const jobSectionSearchRange = 500 @@ -685,7 +759,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { t.Run("dev mode uses custom default branch from buildMaintenanceWorkflowYAML", func(t *testing.T) { // Call buildMaintenanceWorkflowYAML directly to test the branch substitution // without needing a live GitHub API call (FetchDefaultBranch falls back to "main" with no slug) - yaml := buildMaintenanceWorkflowYAML("37 */2 * * *", "Every 2 hours", 1, "ubuntu-slim", ActionModeDev, "v1.0.0", "", nil, nil, "develop") + yaml := buildMaintenanceWorkflowYAML("37 */2 * * *", "Every 2 hours", 1, "ubuntu-slim", ActionModeDev, "v1.0.0", "", nil, nil, "develop", false) if !strings.Contains(yaml, " - develop") { t.Errorf("Push trigger should use the provided default branch 'develop', got:\n%s", yaml[:min(500, len(yaml))]) } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 576444e1ccf..31bddd3173b 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -21,8 +21,9 @@ func buildMaintenanceWorkflowYAML( resolver ActionSHAResolver, configuredRunsOn RunsOnValue, defaultBranch string, + disableLabelTrigger bool, ) string { - maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q", actionMode, minExpiresDays, cronSchedule, defaultBranch) + maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q disableLabelTrigger=%v", actionMode, minExpiresDays, cronSchedule, defaultBranch, disableLabelTrigger) var yaml strings.Builder @@ -57,11 +58,16 @@ on: `) } - yaml.WriteString(` issues: + // Add label-event triggers only when the label-triggered disable job is enabled + if !disableLabelTrigger { + yaml.WriteString(` issues: types: [labeled] pull_request: types: [labeled] - workflow_dispatch: +`) + } + + yaml.WriteString(` workflow_dispatch: inputs: operation: description: 'Optional maintenance operation to run' @@ -621,8 +627,10 @@ jobs: // Add disable_agentic_workflow job triggered by label "agentic-workflows:disable" on issues or PRs. // This job reads the body of the labeled issue/PR to extract the workflow_id from XML comment // markers, disables the corresponding agentic workflow, and posts a confirmation comment. - disableLabelCondition := buildLabeledDisableCondition() - yaml.WriteString(` + // Skipped when disable_label_trigger is set to true in aw.json maintenance config. + if !disableLabelTrigger { + disableLabelCondition := buildLabeledDisableCondition() + yaml.WriteString(` disable_agentic_workflow: if: ${{ ` + RenderCondition(disableLabelCondition) + ` }} runs-on: ` + runsOnValue + ` @@ -654,8 +662,8 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Disable agentic workflow + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Disable agentic workflow uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -668,6 +676,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); await main(); `) + } // Add compile-workflows and zizmor-scan jobs only in dev mode // These jobs are specific to the gh-aw repository and require go.mod, make build, etc. diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 15197dac3ad..3e038979a2f 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -10,6 +10,7 @@ // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72 // expiration (hours) for conclusion failure issues +// "disable_label_trigger": true // set to true to disable the label-triggered disable job // } // maintenance jobs (default: ubuntu-slim) // } // @@ -72,6 +73,10 @@ type MaintenanceConfig struct { // ActionFailureIssueExpires configures expiration (in hours) for action // failure issues opened by the conclusion job. Defaults to 168 (7 days). ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` + + // DisableLabelTrigger disables the label-triggered disable_agentic_workflow job + // when set to true. By default (false or omitted) the job is included. + DisableLabelTrigger bool `json:"disable_label_trigger,omitempty"` } // RepoConfig is the parsed representation of aw.json. diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 6771187de7b..a4be9e23fb8 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -100,6 +100,27 @@ func TestLoadRepoConfig_SchemaViolation(t *testing.T) { assert.Error(t, err, "schema violation should return an error") } +func TestLoadRepoConfig_DisableLabelTrigger(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"disable_label_trigger": true}}`) + + 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.True(t, cfg.Maintenance.DisableLabelTrigger, "disable_label_trigger should be true") +} + +func TestLoadRepoConfig_DisableLabelTrigger_DefaultFalse(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {}}`) + + 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.False(t, cfg.Maintenance.DisableLabelTrigger, "disable_label_trigger should default to false") +} + +// TestLoadRepoConfig_UnknownProperty tests that unknown properties are rejected. func TestLoadRepoConfig_UnknownProperty(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"unknown_property": "value"}`) From ba82e06c9c952a6fa388c5ff4f01fa7cabc5080b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:37:07 +0000 Subject: [PATCH 05/15] feat: move disable label creation to disable_agentic_workflow and add workflow-call-id extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_labels.cjs: remove BUILTIN_LABELS / FIXED_LABEL_COLORS — the agentic-workflows:disable label is no longer created for all operations - disable_agentic_workflow.cjs: add ensureDisableLabelExists() that creates the purple agentic-workflows:disable label (color 8250df) at the start of main(), scoped to the disable operation only - disable_agentic_workflow.cjs: extend extractWorkflowId() to also check markers, extracting the last path segment to handle workflow_dispatch-triggered issues/PRs - create_labels.test.cjs: update tests to reflect BUILTIN_LABELS removal - disable_agentic_workflow.test.cjs: add tests for ensureDisableLabelExists and the new workflow-call-id extraction path Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7fe175e9-2de2-405e-bb89-d77ab1e8556b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_labels.cjs | 34 +----- actions/setup/js/create_labels.test.cjs | 72 +----------- actions/setup/js/disable_agentic_workflow.cjs | 54 ++++++++- .../js/disable_agentic_workflow.test.cjs | 107 +++++++++++++++++- 4 files changed, 165 insertions(+), 102 deletions(-) diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 8ad34b70078..935617893e3 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -5,26 +5,6 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); -/** - * Fixed colors for specific well-known labels. - * These labels always get the specified hex color instead of a deterministic pastel. - */ -const FIXED_LABEL_COLORS = { - "agentic-workflows:disable": "8250df", // GitHub purple -}; - -/** - * Built-in labels that are always created regardless of workflow configuration. - * Each entry is { name: string, color: string, description: string }. - */ -const BUILTIN_LABELS = [ - { - name: "agentic-workflows:disable", - color: FIXED_LABEL_COLORS["agentic-workflows:disable"], - description: "Disable the agentic workflow that created this issue or pull request", - }, -]; - /** * Generate a deterministic pastel hex color string from a label name. * Produces colors in the pastel range (128–191 per channel) for readability. @@ -106,11 +86,6 @@ async function main() { ) ); - // Always include built-in labels - for (const builtin of BUILTIN_LABELS) { - allLabels.add(builtin.name); - } - if (allLabels.size === 0) { core.info("No labels found in safe-outputs configurations — nothing to create"); return; @@ -146,17 +121,14 @@ async function main() { core.info(`ℹ️ Label already exists: ${labelName}`); skipped++; } else { - // Use fixed color and description for known built-in labels; fall back to deterministic pastel - const builtin = BUILTIN_LABELS.find(b => b.name === labelName); - const color = builtin ? builtin.color : deterministicLabelColor(labelName); - const description = builtin ? builtin.description : ""; + const color = deterministicLabelColor(labelName); try { await github.rest.issues.createLabel({ owner, repo, name: labelName, color, - description, + description: "", }); core.info(`✅ Created label: ${labelName}`); created++; @@ -175,4 +147,4 @@ async function main() { core.info(`Done: ${created} label(s) created, ${skipped} already existed`); } -module.exports = { main, deterministicLabelColor, BUILTIN_LABELS, FIXED_LABEL_COLORS }; +module.exports = { main, deterministicLabelColor }; diff --git a/actions/setup/js/create_labels.test.cjs b/actions/setup/js/create_labels.test.cjs index 6b1a13f8a37..5e08c897634 100644 --- a/actions/setup/js/create_labels.test.cjs +++ b/actions/setup/js/create_labels.test.cjs @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { main, deterministicLabelColor, BUILTIN_LABELS } = req("./create_labels.cjs"); +const { main, deterministicLabelColor } = req("./create_labels.cjs"); // ─── global mocks ──────────────────────────────────────────────────────────── @@ -91,15 +91,14 @@ describe("main", () => { stderr: "", }); - // Default: repo has the builtin labels already plus "bug" - mockGithub.paginate.mockResolvedValue([{ name: "bug" }, ...BUILTIN_LABELS.map(b => ({ name: b.name }))]); + // Default: repo has "bug" + mockGithub.paginate.mockResolvedValue([{ name: "bug" }]); mockGithub.rest.issues.createLabel.mockResolvedValue({}); }); it("creates labels that are missing from the repository", async () => { await main(); - // enhancement and docs are missing from the repo; builtin labels are already present const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); expect(names).toContain("enhancement"); expect(names).toContain("docs"); @@ -137,77 +136,18 @@ describe("main", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already existed")); }); - it("always creates the built-in agentic-workflows:disable label", async () => { - // Repo has no labels at all - mockGithub.paginate.mockResolvedValue([]); - mockExec.getExecOutput.mockResolvedValue({ - exitCode: 0, - stdout: JSON.stringify([{ labels: [] }]), - stderr: "", - }); - - await main(); - - const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); - expect(names).toContain("agentic-workflows:disable"); - }); - - it("creates the agentic-workflows:disable label with the fixed purple color", async () => { - mockGithub.paginate.mockResolvedValue([]); - mockExec.getExecOutput.mockResolvedValue({ - exitCode: 0, - stdout: JSON.stringify([{ labels: [] }]), - stderr: "", - }); - - await main(); - - const call = mockGithub.rest.issues.createLabel.mock.calls.find(c => c[0].name === "agentic-workflows:disable"); - expect(call).toBeDefined(); - expect(call[0].color).toBe("8250df"); - }); - - it("skips creating agentic-workflows:disable when it already exists", async () => { - mockGithub.paginate.mockResolvedValue([{ name: "agentic-workflows:disable" }]); - mockExec.getExecOutput.mockResolvedValue({ - exitCode: 0, - stdout: JSON.stringify([{ labels: [] }]), - stderr: "", - }); - - await main(); - - const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); - expect(names).not.toContain("agentic-workflows:disable"); - }); - - it("still processes builtin labels even when no workflow labels are found", async () => { - mockGithub.paginate.mockResolvedValue([]); - mockExec.getExecOutput.mockResolvedValue({ - exitCode: 0, - stdout: JSON.stringify([{ labels: [] }, {}]), - stderr: "", - }); - - await main(); - - // builtin labels are always created even with no workflow labels - const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); - expect(names).toContain("agentic-workflows:disable"); - }); - - it("does nothing when no labels are found and all builtins already exist", async () => { + it("does nothing when no workflow labels are found", async () => { mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: JSON.stringify([{ labels: [] }, {}]), stderr: "", }); - // All builtin labels already exist - mockGithub.paginate.mockResolvedValue(BUILTIN_LABELS.map(b => ({ name: b.name }))); + mockGithub.paginate.mockResolvedValue([]); await main(); expect(mockGithub.rest.issues.createLabel).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No labels found")); }); it("ignores non-string or empty label values", async () => { diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index 626d387a645..01772c24fc6 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -6,6 +6,8 @@ const { ERR_NOT_FOUND } = require("./error_codes.cjs"); const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); const DISABLE_LABEL = "agentic-workflows:disable"; +const DISABLE_LABEL_COLOR = "8250df"; // GitHub purple +const DISABLE_LABEL_DESCRIPTION = "Disable the agentic workflow that created this issue or pull request"; /** * Validate that an extracted workflow ID has a safe, expected format. @@ -24,12 +26,14 @@ function isValidWorkflowId(id) { /** * Extract the workflow_id from an issue or pull request body using XML comment markers. * - * Looks for: + * Looks for (in priority order): * 1. Standalone marker: * 2. Combined marker: + * 3. Workflow-call-id marker: + * (extracts the last path segment to get the workflow ID) * - * The combined marker is only searched within actual HTML comment blocks to prevent - * unintended matches in user-provided content. + * The combined and call-id markers are only searched within actual HTML comment blocks + * to prevent unintended matches in user-provided content. * * @param {string|null|undefined} body - Issue or PR body * @returns {string|null} Workflow ID or null if not found or invalid @@ -52,9 +56,48 @@ function extractWorkflowId(body) { return isValidWorkflowId(id) ? id : null; } + // Try workflow-call-id marker (handles workflow_dispatch): + // The call-id has the form "owner/repo/workflow-id"; extract the last path segment. + const callIdMatch = body.match(//); + if (callIdMatch) { + const segments = callIdMatch[1].trim().split("/"); + const id = segments[segments.length - 1].trim(); + return isValidWorkflowId(id) ? id : null; + } + return null; } +/** + * Ensure the "agentic-workflows:disable" label exists in the repository. + * Creates it with the standard purple color if it is missing. + * This is a no-op (and non-fatal) when the label already exists. + * + * @param {string} owner + * @param {string} repo + * @returns {Promise} + */ +async function ensureDisableLabelExists(owner, repo) { + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: DISABLE_LABEL, + color: DISABLE_LABEL_COLOR, + description: DISABLE_LABEL_DESCRIPTION, + }); + core.info(`✅ Created label '${DISABLE_LABEL}'`); + } catch (err) { + // 422 means the label already exists — expected on most runs + if (err && typeof err === "object" && /** @type {any} */ err.status === 422) { + core.info(`ℹ️ Label '${DISABLE_LABEL}' already exists`); + } else { + // Non-fatal: log a warning but continue — the label may already be present + core.warning(`Failed to ensure label '${DISABLE_LABEL}' exists: ${getErrorMessage(err)}`); + } + } +} + /** * Disable an agentic workflow when the "agentic-workflows:disable" label is applied to an issue or PR. * @@ -73,6 +116,9 @@ async function main() { const { owner, repo } = resolveExecutionOwnerRepo(); + // Ensure the disable label exists so it is available for future use + await ensureDisableLabelExists(owner, repo); + // Get the item (issue or PR) from the payload const item = context.payload.issue || context.payload.pull_request; if (!item) { @@ -205,4 +251,4 @@ async function main() { } } -module.exports = { main, extractWorkflowId, isValidWorkflowId }; +module.exports = { main, extractWorkflowId, isValidWorkflowId, ensureDisableLabelExists }; diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs index c6e0c19f2f2..65bb5d412c1 100644 --- a/actions/setup/js/disable_agentic_workflow.test.cjs +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { extractWorkflowId, main } = req("./disable_agentic_workflow.cjs"); +const { extractWorkflowId, ensureDisableLabelExists, main } = req("./disable_agentic_workflow.cjs"); // ─── global mocks ──────────────────────────────────────────────────────────── @@ -24,6 +24,7 @@ const mockGithub = { issues: { createComment: vi.fn(), removeLabel: vi.fn(), + createLabel: vi.fn(), }, }, }; @@ -56,6 +57,9 @@ describe("main", () => { mockExec.exec.mockResolvedValue(0); mockGithub.rest.issues.createComment.mockResolvedValue({}); mockGithub.rest.issues.removeLabel.mockResolvedValue({}); + // Default: createLabel returns 422 (label already exists) + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); // Restore default context (issue event) global.context = { @@ -156,6 +160,74 @@ describe("main", () => { expect(mockCore.setFailed).toHaveBeenCalled(); }); + + it("calls ensureDisableLabelExists (createLabel) at the start of main", async () => { + // createLabel returning 422 (already exists) is the happy path + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); + + await main(); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + name: "agentic-workflows:disable", + color: "8250df", + }) + ); + }); + + it("continues normally when ensureDisableLabelExists creates the label (201)", async () => { + // Label didn't exist yet — createLabel succeeds + mockGithub.rest.issues.createLabel.mockResolvedValue({}); + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label 'agentic-workflows:disable'")); + expect(mockExec.exec).toHaveBeenCalled(); // disable command still ran + }); +}); + +describe("ensureDisableLabelExists", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.github = mockGithub; + global.core = mockCore; + }); + + it("creates the label when it does not exist", async () => { + mockGithub.rest.issues.createLabel.mockResolvedValue({}); + + await ensureDisableLabelExists("owner", "repo"); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + name: "agentic-workflows:disable", + color: "8250df", + }) + ); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label")); + }); + + it("treats a 422 response as 'already exists' and does not warn", async () => { + const err = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(err); + + await ensureDisableLabelExists("owner", "repo"); + + expect(mockCore.warning).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists")); + }); + + it("logs a warning on non-422 errors but does not throw", async () => { + const err = Object.assign(new Error("Internal Server Error"), { status: 500 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(err); + + await ensureDisableLabelExists("owner", "repo"); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to ensure label")); + }); }); describe("extractWorkflowId", () => { @@ -241,4 +313,37 @@ describe("extractWorkflowId", () => { const body = ""; expect(extractWorkflowId(body)).toBeNull(); }); + + // ─── gh-aw-workflow-call-id fallback ─────────────────────────────────────── + + it("extracts workflow ID from gh-aw-workflow-call-id marker (last path segment)", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("my-workflow"); + }); + + it("extracts workflow ID from call-id with dots and underscores in workflow name", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("code_review.v2"); + }); + + it("prefers standalone marker over call-id when both are present", () => { + const body = "\n"; + expect(extractWorkflowId(body)).toBe("standalone"); + }); + + it("falls back to call-id when only that marker is present", () => { + const body = "Issue body\n"; + expect(extractWorkflowId(body)).toBe("dispatch-workflow"); + }); + + it("returns null when call-id last segment fails validation", () => { + // Segment with path traversal + const body = ""; + expect(extractWorkflowId(body)).toBeNull(); + }); + + it("returns null when call-id last segment contains shell-special characters", () => { + const body = ""; + expect(extractWorkflowId(body)).toBeNull(); + }); }); From 9b92f2f8cf9fb0c82e29195748c2189c0fadcd06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:39:04 +0000 Subject: [PATCH 06/15] fix: address code review feedback in disable_agentic_workflow.cjs - ensureDisableLabelExists: add explicit null check before typeof for 422 guard - extractWorkflowId: add explicit empty-string guard for call-id last segment - Add test for trailing-slash call-id returning null Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7fe175e9-2de2-405e-bb89-d77ab1e8556b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/disable_agentic_workflow.cjs | 5 +++-- actions/setup/js/disable_agentic_workflow.test.cjs | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index 01772c24fc6..103bc8b0869 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -57,11 +57,12 @@ function extractWorkflowId(body) { } // Try workflow-call-id marker (handles workflow_dispatch): - // The call-id has the form "owner/repo/workflow-id"; extract the last path segment. + // The call-id has the form "owner/repo/workflow-id"; extract the last non-empty path segment. const callIdMatch = body.match(//); if (callIdMatch) { const segments = callIdMatch[1].trim().split("/"); const id = segments[segments.length - 1].trim(); + if (id.length === 0) return null; return isValidWorkflowId(id) ? id : null; } @@ -89,7 +90,7 @@ async function ensureDisableLabelExists(owner, repo) { core.info(`✅ Created label '${DISABLE_LABEL}'`); } catch (err) { // 422 means the label already exists — expected on most runs - if (err && typeof err === "object" && /** @type {any} */ err.status === 422) { + if (err !== null && typeof err === "object" && /** @type {any} */ err.status === 422) { core.info(`ℹ️ Label '${DISABLE_LABEL}' already exists`); } else { // Non-fatal: log a warning but continue — the label may already be present diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs index 65bb5d412c1..16fee5e6bab 100644 --- a/actions/setup/js/disable_agentic_workflow.test.cjs +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -346,4 +346,9 @@ describe("extractWorkflowId", () => { const body = ""; expect(extractWorkflowId(body)).toBeNull(); }); + + it("returns null when call-id ends with a trailing slash (empty last segment)", () => { + const body = ""; + expect(extractWorkflowId(body)).toBeNull(); + }); }); From 09370b88631b6f8322dee3e188e2939a91cb74ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:49:58 +0000 Subject: [PATCH 07/15] feat: rename disable_label_trigger to label_trigger_disable with default-true semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaintenanceConfig.DisableLabelTrigger bool → LabelTriggerDisable *bool - *bool so nil (omitted) is treated as true (feature enabled by default) - Only label_trigger_disable: false explicitly opts out of the label-triggered job - Add IsLabelTriggerEnabled() helper on MaintenanceConfig - Update maintenance_workflow.go to use the new helper - Update repo_config_schema.json: rename field and update description - Update all tests to use the new field name and semantics Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ef799e85-84c1-421c-ab8b-ffff63cffdab Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/repo_config_schema.json | 4 ++-- pkg/workflow/maintenance_workflow.go | 4 ++-- pkg/workflow/maintenance_workflow_test.go | 11 ++++++----- pkg/workflow/repo_config.go | 18 +++++++++++++---- pkg/workflow/repo_config_test.go | 23 ++++++++++++++++++---- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index b14fbac95f4..c76933711fc 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -52,8 +52,8 @@ "minimum": 1, "examples": [24, 72, 168] }, - "disable_label_trigger": { - "description": "Set to true to disable the label-triggered disable_agentic_workflow job. When absent or false (default), the job is included in the maintenance workflow.", + "label_trigger_disable": { + "description": "Set to false to disable the label-triggered disable_agentic_workflow job. When absent or true (default), the job is included in the maintenance workflow.", "type": "boolean" } } diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index dca4ff8694a..e136a4dc608 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -125,10 +125,10 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s // Determine the runs-on value to use for all maintenance jobs. const defaultRunsOn = "ubuntu-slim" var configuredRunsOn RunsOnValue - var disableLabelTrigger bool + disableLabelTrigger := false // default: include the label-triggered job if repoConfig != nil && repoConfig.Maintenance != nil { configuredRunsOn = repoConfig.Maintenance.RunsOn - disableLabelTrigger = repoConfig.Maintenance.DisableLabelTrigger + disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() } runsOnValue := FormatRunsOn(configuredRunsOn, defaultRunsOn) diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index d59ec8341b8..2d1bc4a3b0b 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -656,8 +656,9 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Disabled(t *testing.T) } tmpDir := t.TempDir() + falseVal := false cfg := &RepoConfig{ - Maintenance: &MaintenanceConfig{DisableLabelTrigger: true}, + Maintenance: &MaintenanceConfig{LabelTriggerDisable: &falseVal}, } err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { @@ -671,15 +672,15 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Disabled(t *testing.T) // Label-event triggers should be absent if strings.Contains(yaml, " issues:\n types: [labeled]") { - t.Error("When disable_label_trigger is true the issues labeled trigger should not be present") + t.Error("When label_trigger_disable is false the issues labeled trigger should not be present") } if strings.Contains(yaml, " pull_request:\n types: [labeled]") { - t.Error("When disable_label_trigger is true the pull_request labeled trigger should not be present") + t.Error("When label_trigger_disable is false the pull_request labeled trigger should not be present") } // The disable_agentic_workflow job should be absent if strings.Contains(yaml, "disable_agentic_workflow:") { - t.Error("When disable_label_trigger is true the disable_agentic_workflow job should not be present") + t.Error("When label_trigger_disable is false the disable_agentic_workflow job should not be present") } } @@ -694,7 +695,7 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Default(t *testing.T) { } tmpDir := t.TempDir() - // Default: DisableLabelTrigger is false (omitted) + // Default: LabelTriggerDisable is nil (omitted) → treated as true → job included err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 3e038979a2f..49d8813c4cf 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -10,7 +10,7 @@ // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72 // expiration (hours) for conclusion failure issues -// "disable_label_trigger": true // set to true to disable the label-triggered disable job +// "label_trigger_disable": false // set to false to disable the label-triggered disable job // } // maintenance jobs (default: ubuntu-slim) // } // @@ -74,9 +74,19 @@ type MaintenanceConfig struct { // failure issues opened by the conclusion job. Defaults to 168 (7 days). ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` - // DisableLabelTrigger disables the label-triggered disable_agentic_workflow job - // when set to true. By default (false or omitted) the job is included. - DisableLabelTrigger bool `json:"disable_label_trigger,omitempty"` + // LabelTriggerDisable controls the label-triggered disable_agentic_workflow job. + // When nil (omitted) or true (default), the job is included in the maintenance workflow. + // Set to false explicitly to exclude the job and its associated issue/pull_request triggers. + LabelTriggerDisable *bool `json:"label_trigger_disable,omitempty"` +} + +// IsLabelTriggerEnabled returns true unless label_trigger_disable is explicitly set to false. +// The default (nil / omitted) is treated as enabled (true). +func (m *MaintenanceConfig) IsLabelTriggerEnabled() bool { + if m == nil || m.LabelTriggerDisable == nil { + return true + } + return *m.LabelTriggerDisable } // RepoConfig is the parsed representation of aw.json. diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index a4be9e23fb8..1d0de203cba 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -102,22 +102,37 @@ func TestLoadRepoConfig_SchemaViolation(t *testing.T) { func TestLoadRepoConfig_DisableLabelTrigger(t *testing.T) { dir := t.TempDir() - writeAWJSON(t, dir, `{"maintenance": {"disable_label_trigger": true}}`) + writeAWJSON(t, dir, `{"maintenance": {"label_trigger_disable": false}}`) 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.True(t, cfg.Maintenance.DisableLabelTrigger, "disable_label_trigger should be true") + require.NotNil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be set") + assert.False(t, *cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be false when explicitly set") + assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label trigger should be disabled when label_trigger_disable is false") } -func TestLoadRepoConfig_DisableLabelTrigger_DefaultFalse(t *testing.T) { +func TestLoadRepoConfig_LabelTriggerDisable_DefaultTrue(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"maintenance": {}}`) 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.False(t, cfg.Maintenance.DisableLabelTrigger, "disable_label_trigger should default to false") + assert.Nil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be nil when not specified") + assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label trigger should be enabled by default (nil = true)") +} + +func TestLoadRepoConfig_LabelTriggerDisable_ExplicitTrue(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"label_trigger_disable": true}}`) + + 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") + require.NotNil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be set") + assert.True(t, *cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be true") + assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label trigger should be enabled when label_trigger_disable is true") } // TestLoadRepoConfig_UnknownProperty tests that unknown properties are rejected. From 6660c5d091d27ad680e2644439d669dff9a01309 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:51:45 +0000 Subject: [PATCH 08/15] docs: clarify label_trigger_disable field and test assertion messages Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ef799e85-84c1-421c-ab8b-ffff63cffdab Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/repo_config.go | 5 +++-- pkg/workflow/repo_config_test.go | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 49d8813c4cf..684edf55d0c 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -75,8 +75,9 @@ type MaintenanceConfig struct { ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` // LabelTriggerDisable controls the label-triggered disable_agentic_workflow job. - // When nil (omitted) or true (default), the job is included in the maintenance workflow. - // Set to false explicitly to exclude the job and its associated issue/pull_request triggers. + // The value represents whether the feature is active: true (or omitted/nil) means + // the job IS included; false means the job is excluded. + // To opt out, set label_trigger_disable: false in aw.json. LabelTriggerDisable *bool `json:"label_trigger_disable,omitempty"` } diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 1d0de203cba..84c587e754f 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -109,7 +109,7 @@ func TestLoadRepoConfig_DisableLabelTrigger(t *testing.T) { require.NotNil(t, cfg.Maintenance, "maintenance config should be set") require.NotNil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be set") assert.False(t, *cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be false when explicitly set") - assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label trigger should be disabled when label_trigger_disable is false") + assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "setting label_trigger_disable: false explicitly opts out — the job should not be included") } func TestLoadRepoConfig_LabelTriggerDisable_DefaultTrue(t *testing.T) { @@ -132,7 +132,7 @@ func TestLoadRepoConfig_LabelTriggerDisable_ExplicitTrue(t *testing.T) { require.NotNil(t, cfg.Maintenance, "maintenance config should be set") require.NotNil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be set") assert.True(t, *cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be true") - assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label trigger should be enabled when label_trigger_disable is true") + assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label_trigger_disable: true keeps the job enabled (true = feature active, despite the 'disable' suffix in the field name)") } // TestLoadRepoConfig_UnknownProperty tests that unknown properties are rejected. From fa01332cc0b21b3bb1036394aea68a63e5fcecbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:11:06 +0000 Subject: [PATCH 09/15] feat: rename to label_triggers, issues-only trigger, add label_apply_safe_outputs job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename MaintenanceConfig.LabelTriggers (*bool, label_triggers JSON key) to cover all label-triggered jobs (disable + apply-safe-outputs) - Remove pull_request: [labeled] trigger — issues-only from now on - Update buildLabeledDisableCondition() to check only 'issues' event - Add buildLabeledApplySafeOutputsCondition() for new job - Add label_apply_safe_outputs job in agentics-maintenance.yml: - Trigger: issues:labeled with agentic-workflows:apply-safe-outputs - Extracts run URL from issue body XML comments (run:/id: fields) - Delegates to apply_safe_outputs_replay.cjs - Posts success comment and removes label when done - Add actions/setup/js/label_apply_safe_outputs.cjs (new) - Add actions/setup/js/label_apply_safe_outputs.test.cjs (new, 19 tests) - Update disable_agentic_workflow.cjs: remove pull_request branch - Update all Go and JS tests to reflect new behavior Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0f3f9c0f-0fc7-41b4-9151-a7011a4cc667 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 49 +++- actions/setup/js/disable_agentic_workflow.cjs | 31 +- .../js/disable_agentic_workflow.test.cjs | 5 +- actions/setup/js/label_apply_safe_outputs.cjs | 191 ++++++++++++ .../js/label_apply_safe_outputs.test.cjs | 273 ++++++++++++++++++ pkg/parser/schemas/repo_config_schema.json | 4 +- pkg/workflow/maintenance_conditions.go | 25 +- pkg/workflow/maintenance_workflow_test.go | 86 ++++-- pkg/workflow/maintenance_workflow_yaml.go | 56 +++- pkg/workflow/repo_config.go | 19 +- pkg/workflow/repo_config_test.go | 26 +- 11 files changed, 685 insertions(+), 80 deletions(-) create mode 100644 actions/setup/js/label_apply_safe_outputs.cjs create mode 100644 actions/setup/js/label_apply_safe_outputs.test.cjs diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index f6b5679151b..54544e7777c 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -42,8 +42,6 @@ on: - '.github/workflows/*.md' issues: types: [labeled] - pull_request: - types: [labeled] workflow_dispatch: inputs: operation: @@ -559,13 +557,12 @@ jobs: await main(); disable_agentic_workflow: - if: ${{ (!(github.event.repository.fork)) && (github.event_name == 'issues' || github.event_name == 'pull_request') && github.event.label.name == 'agentic-workflows:disable' }} + if: ${{ (!(github.event.repository.fork)) && github.event_name == 'issues' && github.event.label.name == 'agentic-workflows:disable' }} runs-on: ubuntu-slim permissions: actions: write contents: read issues: write - pull-requests: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -609,6 +606,50 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); await main(); + label_apply_safe_outputs: + if: ${{ (!(github.event.repository.fork)) && github.event_name == 'issues' && github.event.label.name == 'agentic-workflows:apply-safe-outputs' }} + runs-on: ubuntu-slim + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Apply safe outputs from referenced run + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/label_apply_safe_outputs.cjs'); + await main(); + compile-workflows: if: ${{ (!(github.event.repository.fork)) && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} runs-on: ubuntu-slim diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index 103bc8b0869..e8d1ff316dd 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -100,9 +100,9 @@ async function ensureDisableLabelExists(owner, repo) { } /** - * Disable an agentic workflow when the "agentic-workflows:disable" label is applied to an issue or PR. + * Disable an agentic workflow when the "agentic-workflows:disable" label is applied to an issue. * - * Reads the labeled issue/PR body to extract the workflow_id from XML comment markers, + * Reads the labeled issue body to extract the workflow_id from XML comment markers, * disables the corresponding agentic workflow using `gh aw disable`, and posts a comment * confirming the action. * @@ -110,8 +110,8 @@ async function ensureDisableLabelExists(owner, repo) { */ async function main() { const eventName = context.eventName; - if (eventName !== "issues" && eventName !== "pull_request") { - core.info(`Skipping: unexpected event type '${eventName}'`); + if (eventName !== "issues") { + core.info(`Skipping: unexpected event type '${eventName}' (expected 'issues')`); return; } @@ -120,10 +120,10 @@ async function main() { // Ensure the disable label exists so it is available for future use await ensureDisableLabelExists(owner, repo); - // Get the item (issue or PR) from the payload - const item = context.payload.issue || context.payload.pull_request; + // Get the issue from the payload + const item = context.payload.issue; if (!item) { - core.warning("No issue or pull_request found in event payload"); + core.warning("No issue found in event payload"); return; } @@ -135,15 +135,14 @@ async function main() { return; } - const itemType = eventName === "issues" ? "issue" : "pull request"; - core.info(`Processing ${itemType} #${itemNumber} labeled with '${labelName}'`); + core.info(`Processing issue #${itemNumber} labeled with '${labelName}'`); // Extract workflow ID from body XML comment markers const body = item.body || ""; const workflowId = extractWorkflowId(body); if (!workflowId) { - core.warning(`Could not find workflow ID in ${itemType} #${itemNumber} body. ` + `Expected a marker.`); + core.warning(`Could not find workflow ID in issue #${itemNumber} body. Expected a marker.`); await github.rest.issues.createComment({ owner, repo, @@ -151,15 +150,15 @@ async function main() { body: `> [!WARNING]\n` + `> **Could not disable agentic workflow**\n>\n` + - `> No workflow ID marker was found in this ${itemType}'s body. ` + - `The \`${DISABLE_LABEL}\` label can only be used on issues and pull requests that were created by an agentic workflow ` + + `> No workflow ID marker was found in this issue's body. ` + + `The \`${DISABLE_LABEL}\` label can only be used on issues that were created by an agentic workflow ` + `(they contain a \`\` marker).\n>\n` + `> To disable a workflow manually, use:\n` + `> \`\`\`\n` + `> gh aw disable \n` + `> \`\`\``, }); - core.setFailed(`${ERR_NOT_FOUND}: No workflow ID marker found in ${itemType} #${itemNumber}`); + core.setFailed(`${ERR_NOT_FOUND}: No workflow ID marker found in issue #${itemNumber}`); return; } @@ -220,7 +219,7 @@ async function main() { core.info(`Successfully disabled workflow '${workflowId}'`); - // Post a success comment on the issue/PR + // Post a success comment on the issue await github.rest.issues.createComment({ owner, repo, @@ -235,7 +234,7 @@ async function main() { ``, }); - core.info(`Posted disable confirmation comment on ${itemType} #${itemNumber}`); + core.info(`Posted disable confirmation comment on issue #${itemNumber}`); // Remove the disable label now that the action is complete try { @@ -245,7 +244,7 @@ async function main() { issue_number: itemNumber, name: DISABLE_LABEL, }); - core.info(`Removed label '${DISABLE_LABEL}' from ${itemType} #${itemNumber}`); + core.info(`Removed label '${DISABLE_LABEL}' from issue #${itemNumber}`); } catch (err) { // Non-fatal: the disable already succeeded, just log a warning core.warning(`Failed to remove label '${DISABLE_LABEL}': ${getErrorMessage(err)}`); diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs index 16fee5e6bab..e349d71e034 100644 --- a/actions/setup/js/disable_agentic_workflow.test.cjs +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -99,7 +99,7 @@ describe("main", () => { ); }); - it("removes label after success on pull_request events too", async () => { + it("skips silently when event type is pull_request", async () => { global.context = { eventName: "pull_request", repo: { owner: "test-owner", repo: "test-repo" }, @@ -111,7 +111,8 @@ describe("main", () => { await main(); - expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 7, name: "agentic-workflows:disable" })); + expect(mockExec.exec).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); }); it("does not remove label when no workflow ID marker is found", async () => { diff --git a/actions/setup/js/label_apply_safe_outputs.cjs b/actions/setup/js/label_apply_safe_outputs.cjs new file mode 100644 index 00000000000..35a78998a3e --- /dev/null +++ b/actions/setup/js/label_apply_safe_outputs.cjs @@ -0,0 +1,191 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_NOT_FOUND } = require("./error_codes.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); + +const APPLY_SAFE_OUTPUTS_LABEL = "agentic-workflows:apply-safe-outputs"; +const APPLY_SAFE_OUTPUTS_LABEL_COLOR = "8250df"; // GitHub purple +const APPLY_SAFE_OUTPUTS_LABEL_DESCRIPTION = "Re-apply the safe outputs from the agentic workflow run referenced in this issue"; + +/** + * Extract a workflow run URL or numeric run ID from an issue body. + * + * Looks for (in priority order): + * 1. Combined marker run field: + * 2. Combined marker id field: + * 3. Standalone run-url marker: + * + * @param {string|null|undefined} body - Issue body + * @returns {string|null} Run URL or numeric run ID, or null if not found + */ +function extractRunUrl(body) { + if (!body) return null; + + // 1. Combined marker — extract the run: field (full URL) + const runUrlMatch = body.match(/ + const standaloneMatch = body.match(//); + if (standaloneMatch) { + return standaloneMatch[1].trim(); + } + + return null; +} + +/** + * Ensure the "agentic-workflows:apply-safe-outputs" label exists in the repository. + * Creates it with the standard purple color if it is missing. + * This is a no-op (and non-fatal) when the label already exists. + * + * @param {string} owner + * @param {string} repo + * @returns {Promise} + */ +async function ensureApplySafeOutputsLabelExists(owner, repo) { + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: APPLY_SAFE_OUTPUTS_LABEL, + color: APPLY_SAFE_OUTPUTS_LABEL_COLOR, + description: APPLY_SAFE_OUTPUTS_LABEL_DESCRIPTION, + }); + core.info(`✅ Created label '${APPLY_SAFE_OUTPUTS_LABEL}'`); + } catch (err) { + // 422 means the label already exists — expected on most runs + if (err !== null && typeof err === "object" && /** @type {any} */ err.status === 422) { + core.info(`ℹ️ Label '${APPLY_SAFE_OUTPUTS_LABEL}' already exists`); + } else { + // Non-fatal: log a warning but continue — the label may already be present + core.warning(`Failed to ensure label '${APPLY_SAFE_OUTPUTS_LABEL}' exists: ${getErrorMessage(err)}`); + } + } +} + +/** + * Re-apply safe outputs from a previous workflow run when the + * "agentic-workflows:apply-safe-outputs" label is applied to an issue. + * + * Reads the labeled issue body to extract a workflow run URL or run ID from XML comment + * markers, re-applies the safe outputs from that run, posts a success comment, and + * removes the label. + * + * @returns {Promise} + */ +async function main() { + const eventName = context.eventName; + if (eventName !== "issues") { + core.info(`Skipping: unexpected event type '${eventName}' (expected 'issues')`); + return; + } + + const { owner, repo } = resolveExecutionOwnerRepo(); + + // Ensure the label exists so it is available for future use + await ensureApplySafeOutputsLabelExists(owner, repo); + + // Get the issue from the payload + const item = context.payload.issue; + if (!item) { + core.warning("No issue found in event payload"); + return; + } + + const issueNumber = item.number; + const labelName = context.payload.label?.name; + + if (labelName !== APPLY_SAFE_OUTPUTS_LABEL) { + core.info(`Skipping: label '${labelName}' is not '${APPLY_SAFE_OUTPUTS_LABEL}'`); + return; + } + + core.info(`Processing issue #${issueNumber} labeled with '${labelName}'`); + + // Extract run URL from body XML comment markers + const body = item.body || ""; + const runUrl = extractRunUrl(body); + + if (!runUrl) { + core.warning(`Could not find run URL in issue #${issueNumber} body.`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: + `> [!WARNING]\n` + + `> **Could not apply safe outputs**\n>\n` + + `> No workflow run reference was found in this issue's body. ` + + `The \`${APPLY_SAFE_OUTPUTS_LABEL}\` label can only be used on issues that were created by an agentic workflow ` + + `(they contain a \`\` marker with a run URL).\n>\n` + + `> To apply safe outputs manually, use the maintenance workflow with the \`safe_outputs\` operation and supply the run URL.`, + }); + core.setFailed(`${ERR_NOT_FOUND}: No run URL marker found in issue #${issueNumber}`); + return; + } + + core.info(`Found run reference: ${runUrl}`); + + // Set GH_AW_RUN_URL so apply_safe_outputs_replay.cjs can consume it + process.env.GH_AW_RUN_URL = runUrl; + + // Delegate to the existing replay driver + const { main: replayMain } = require("./apply_safe_outputs_replay.cjs"); + try { + await replayMain(); + } catch (err) { + const msg = getErrorMessage(err); + core.error(`Failed to apply safe outputs: ${msg}`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: + `> [!WARNING]\n` + + `> **Failed to apply safe outputs from run \`${runUrl}\`**\n>\n` + + `> ${msg}\n>\n` + + `> Please check the [workflow run logs](${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID || ""}) for details.`, + }); + core.setFailed(`Failed to apply safe outputs: ${msg}`); + return; + } + + core.info(`Successfully applied safe outputs from run ${runUrl}`); + + // Post a success comment on the issue + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `✅ Safe outputs from [run \`${runUrl}\`](${runUrl}) have been applied.\n\n` + ``, + }); + + core.info(`Posted success comment on issue #${issueNumber}`); + + // Remove the label now that the action is complete + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: APPLY_SAFE_OUTPUTS_LABEL, + }); + core.info(`Removed label '${APPLY_SAFE_OUTPUTS_LABEL}' from issue #${issueNumber}`); + } catch (err) { + // Non-fatal: the apply already succeeded, just log a warning + core.warning(`Failed to remove label '${APPLY_SAFE_OUTPUTS_LABEL}': ${getErrorMessage(err)}`); + } +} + +module.exports = { main, extractRunUrl, ensureApplySafeOutputsLabelExists }; diff --git a/actions/setup/js/label_apply_safe_outputs.test.cjs b/actions/setup/js/label_apply_safe_outputs.test.cjs new file mode 100644 index 00000000000..1019971ae9c --- /dev/null +++ b/actions/setup/js/label_apply_safe_outputs.test.cjs @@ -0,0 +1,273 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { extractRunUrl, ensureApplySafeOutputsLabelExists, main } = req("./label_apply_safe_outputs.cjs"); + +// ─── global mocks ──────────────────────────────────────────────────────────── + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), +}; + +const mockExec = { + exec: vi.fn(), +}; + +const mockGithub = { + rest: { + issues: { + createComment: vi.fn(), + removeLabel: vi.fn(), + createLabel: vi.fn(), + }, + }, +}; + +// Default context: issue labeled with apply-safe-outputs, body has a combined XML marker +const defaultIssueBody = ``; + +const mockContext = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 42, body: defaultIssueBody }, + label: { name: "agentic-workflows:apply-safe-outputs" }, + }, +}; + +global.core = mockCore; +global.exec = mockExec; +global.github = mockGithub; +global.context = mockContext; + +// ─── extractRunUrl ──────────────────────────────────────────────────────────── + +describe("extractRunUrl", () => { + it("extracts run URL from combined marker run: field", () => { + const body = ``; + expect(extractRunUrl(body)).toBe("https://github.com/owner/repo/actions/runs/123"); + }); + + it("extracts numeric run ID from combined marker id: field when run: is absent", () => { + const body = ``; + expect(extractRunUrl(body)).toBe("456"); + }); + + it("run: field takes priority over id: field", () => { + const body = ``; + expect(extractRunUrl(body)).toBe("https://github.com/o/r/actions/runs/222"); + }); + + it("extracts run URL from standalone gh-aw-run-url marker", () => { + const body = ``; + expect(extractRunUrl(body)).toBe("https://github.com/owner/repo/actions/runs/789"); + }); + + it("extracts numeric run ID from standalone marker", () => { + const body = ``; + expect(extractRunUrl(body)).toBe("5555"); + }); + + it("combined marker takes priority over standalone marker", () => { + const body = `\n` + ``; + expect(extractRunUrl(body)).toBe("https://github.com/o/r/actions/runs/100"); + }); + + it("returns null when body is empty", () => { + expect(extractRunUrl("")).toBeNull(); + }); + + it("returns null when body is null", () => { + expect(extractRunUrl(null)).toBeNull(); + }); + + it("returns null when body has no recognized markers", () => { + expect(extractRunUrl("Just a plain issue body with no markers.")).toBeNull(); + }); + + it("does not match partial or malformed combined markers", () => { + const body = ``; + expect(extractRunUrl(body)).toBeNull(); + }); +}); + +// ─── ensureApplySafeOutputsLabelExists ─────────────────────────────────────── + +describe("ensureApplySafeOutputsLabelExists", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.github = mockGithub; + global.core = mockCore; + }); + + it("creates the label when it does not exist", async () => { + mockGithub.rest.issues.createLabel.mockResolvedValue({}); + + await ensureApplySafeOutputsLabelExists("owner", "repo"); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + name: "agentic-workflows:apply-safe-outputs", + color: "8250df", + }) + ); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label")); + }); + + it("logs info when label already exists (422)", async () => { + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); + + await ensureApplySafeOutputsLabelExists("owner", "repo"); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists")); + expect(mockCore.warning).not.toHaveBeenCalled(); + }); + + it("logs a warning for unexpected errors (non-fatal)", async () => { + mockGithub.rest.issues.createLabel.mockRejectedValue(new Error("Network error")); + + await ensureApplySafeOutputsLabelExists("owner", "repo"); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to ensure label")); + }); +}); + +// ─── main ───────────────────────────────────────────────────────────────────── + +describe("main", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.GH_TOKEN = "fake-token"; + process.env.GITHUB_TOKEN = "fake-token"; + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; + process.env.GITHUB_SERVER_URL = "https://github.com"; + process.env.GITHUB_RUN_ID = "999"; + delete process.env.GH_AW_RUN_URL; + + // Default: createLabel returns 422 (already exists) + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); + mockGithub.rest.issues.createComment.mockResolvedValue({}); + mockGithub.rest.issues.removeLabel.mockResolvedValue({}); + + // Restore default context + global.context = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 42, body: defaultIssueBody }, + label: { name: "agentic-workflows:apply-safe-outputs" }, + }, + }; + }); + + it("skips silently when event type is not 'issues'", async () => { + global.context = { ...global.context, eventName: "pull_request" }; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping")); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it("skips when the label is not the apply-safe-outputs label", async () => { + global.context = { + ...global.context, + payload: { + issue: { number: 42, body: defaultIssueBody }, + label: { name: "some-other-label" }, + }, + }; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping")); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it("calls setFailed and posts a warning comment when no run URL is found", async () => { + global.context = { + ...global.context, + payload: { + issue: { number: 5, body: "Plain issue body with no markers." }, + label: { name: "agentic-workflows:apply-safe-outputs" }, + }, + }; + + await main(); + + expect(mockCore.setFailed).toHaveBeenCalled(); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("Could not apply safe outputs"), + }) + ); + expect(mockGithub.rest.issues.removeLabel).not.toHaveBeenCalled(); + }); + + it("ensures the apply-safe-outputs label exists at the start of main", async () => { + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); + + // The issue body has no run URL, so main() will call setFailed after ensureLabel — + // we only care that createLabel was called with the right args. + global.context = { + ...global.context, + payload: { + issue: { number: 42, body: "no markers here" }, + label: { name: "agentic-workflows:apply-safe-outputs" }, + }, + }; + + await main(); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + name: "agentic-workflows:apply-safe-outputs", + color: "8250df", + }) + ); + }); + + it("removes the label after a successful apply", async () => { + // Stub the replay driver to succeed + const replayModule = req("./apply_safe_outputs_replay.cjs"); + const originalMain = replayModule.main; + replayModule.main = vi.fn().mockResolvedValue(undefined); + + await main(); + + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ + issue_number: 42, + name: "agentic-workflows:apply-safe-outputs", + }) + ); + + // Restore + replayModule.main = originalMain; + }); + + it("logs a warning when label removal fails but does not fail the step", async () => { + const replayModule = req("./apply_safe_outputs_replay.cjs"); + const originalMain = replayModule.main; + replayModule.main = vi.fn().mockResolvedValue(undefined); + + mockGithub.rest.issues.removeLabel.mockRejectedValue(new Error("Not Found")); + + await main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to remove label")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + + // Restore + replayModule.main = originalMain; + }); +}); diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index c76933711fc..e2ad0f8dadf 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -52,8 +52,8 @@ "minimum": 1, "examples": [24, 72, 168] }, - "label_trigger_disable": { - "description": "Set to false to disable the label-triggered disable_agentic_workflow job. When absent or true (default), the job is included in the maintenance workflow.", + "label_triggers": { + "description": "Set to false to disable all label-triggered jobs (disable_agentic_workflow, label_apply_safe_outputs, etc.). When absent or true (default), all label-triggered jobs are included in the maintenance workflow.", "type": "boolean" } } diff --git a/pkg/workflow/maintenance_conditions.go b/pkg/workflow/maintenance_conditions.go index 35fa74be1f0..1ec68acb39b 100644 --- a/pkg/workflow/maintenance_conditions.go +++ b/pkg/workflow/maintenance_conditions.go @@ -103,16 +103,13 @@ func buildDispatchOperationCondition(operation string) ConditionNode { } // buildLabeledDisableCondition creates a condition for the disable_agentic_workflow job -// that triggers when an issue or pull request is labeled with "agentic-workflows:disable". -// Condition: !fork && (event_name == 'issues' || event_name == 'pull_request') && event.label.name == 'agentic-workflows:disable' +// that triggers when an issue is labeled with "agentic-workflows:disable". +// Condition: !fork && event_name == 'issues' && event.label.name == 'agentic-workflows:disable' func buildLabeledDisableCondition() ConditionNode { return BuildAnd( buildNotForkCondition(), BuildAnd( - BuildOr( - BuildEventTypeEquals("issues"), - BuildEventTypeEquals("pull_request"), - ), + BuildEventTypeEquals("issues"), BuildEquals( BuildPropertyAccess("github.event.label.name"), BuildStringLiteral("agentic-workflows:disable"), @@ -121,6 +118,22 @@ func buildLabeledDisableCondition() ConditionNode { ) } +// buildLabeledApplySafeOutputsCondition creates a condition for the label_apply_safe_outputs job +// that triggers when an issue is labeled with "agentic-workflows:apply-safe-outputs". +// Condition: !fork && event_name == 'issues' && event.label.name == 'agentic-workflows:apply-safe-outputs' +func buildLabeledApplySafeOutputsCondition() ConditionNode { + return BuildAnd( + buildNotForkCondition(), + BuildAnd( + BuildEventTypeEquals("issues"), + BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral("agentic-workflows:apply-safe-outputs"), + ), + ), + ) +} + // buildRunOperationCondition creates the condition for the unified run_operation // job that handles all dispatch/call operations except the ones with dedicated jobs. // Condition: (dispatch || call) && operation != ” && operation != each excluded && !fork. diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 2d1bc4a3b0b..eec38ca709d 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -563,12 +563,12 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { const jobSectionSearchRange = 2000 - // Verify the label triggers are present in the on: section + // Verify only the issues label trigger is present (pull_request is no longer supported) if !strings.Contains(yaml, " issues:\n types: [labeled]") { t.Error("Maintenance workflow should include issues: types: [labeled] trigger") } - if !strings.Contains(yaml, " pull_request:\n types: [labeled]") { - t.Error("Maintenance workflow should include pull_request: types: [labeled] trigger") + if strings.Contains(yaml, " pull_request:\n types: [labeled]") { + t.Error("Maintenance workflow must NOT include pull_request: types: [labeled] trigger (issues-only)") } // Verify the disable_agentic_workflow job exists @@ -578,9 +578,12 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { } disableJobSection := yaml[disableJobIdx : disableJobIdx+jobSectionSearchRange] - // Verify the condition triggers on issues and pull_request label events - if !strings.Contains(disableJobSection, "github.event_name == 'issues' || github.event_name == 'pull_request'") { - t.Errorf("disable_agentic_workflow job should trigger on issues and pull_request events in:\n%s", disableJobSection) + // Verify the condition triggers only on issues label events (not pull_request) + if !strings.Contains(disableJobSection, "github.event_name == 'issues'") { + t.Errorf("disable_agentic_workflow job should trigger on issues events in:\n%s", disableJobSection) + } + if strings.Contains(disableJobSection, "github.event_name == 'pull_request'") { + t.Errorf("disable_agentic_workflow job must NOT trigger on pull_request events (issues-only) in:\n%s", disableJobSection) } if !strings.Contains(disableJobSection, "github.event.label.name == 'agentic-workflows:disable'") { t.Errorf("disable_agentic_workflow job should check for agentic-workflows:disable label in:\n%s", disableJobSection) @@ -589,7 +592,7 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { t.Errorf("disable_agentic_workflow job should exclude forks in:\n%s", disableJobSection) } - // Verify required permissions + // Verify required permissions (no pull-requests: write since issues-only) if !strings.Contains(disableJobSection, "actions: write") { t.Errorf("disable_agentic_workflow job should have actions: write permission in:\n%s", disableJobSection) } @@ -602,8 +605,8 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { if !strings.Contains(disableJobSection, "issues: write") { t.Errorf("disable_agentic_workflow job should have issues: write permission in:\n%s", disableJobSection) } - if !strings.Contains(disableJobSection, "pull-requests: write") { - t.Errorf("disable_agentic_workflow job should have pull-requests: write permission in:\n%s", disableJobSection) + if strings.Contains(disableJobSection, "pull-requests: write") { + t.Errorf("disable_agentic_workflow job must NOT have pull-requests: write (issues-only) in:\n%s", disableJobSection) } // Verify the job uses disable_agentic_workflow.cjs @@ -621,12 +624,12 @@ func TestBuildLabeledDisableCondition(t *testing.T) { condition := buildLabeledDisableCondition() rendered := RenderCondition(condition) - // Should include both issues and pull_request event names + // Should only include issues event (not pull_request — issues-only by design) if !strings.Contains(rendered, "github.event_name == 'issues'") { t.Errorf("Condition should include issues event, got: %s", rendered) } - if !strings.Contains(rendered, "github.event_name == 'pull_request'") { - t.Errorf("Condition should include pull_request event, got: %s", rendered) + if strings.Contains(rendered, "github.event_name == 'pull_request'") { + t.Errorf("Condition must not include pull_request event (issues-only), got: %s", rendered) } // Should check the label name @@ -645,7 +648,30 @@ func TestBuildLabeledDisableCondition(t *testing.T) { } } -func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Disabled(t *testing.T) { +func TestBuildLabeledApplySafeOutputsCondition(t *testing.T) { + condition := buildLabeledApplySafeOutputsCondition() + rendered := RenderCondition(condition) + + // Should only include issues event + if !strings.Contains(rendered, "github.event_name == 'issues'") { + t.Errorf("Condition should include issues event, got: %s", rendered) + } + if strings.Contains(rendered, "github.event_name == 'pull_request'") { + t.Errorf("Condition must not include pull_request event (issues-only), got: %s", rendered) + } + + // Should check the apply-safe-outputs label name + if !strings.Contains(rendered, "github.event.label.name == 'agentic-workflows:apply-safe-outputs'") { + t.Errorf("Condition should check for agentic-workflows:apply-safe-outputs label, got: %s", rendered) + } + + // Should exclude forks + if !strings.Contains(rendered, "github.event.repository.fork") { + t.Errorf("Condition should exclude forks, got: %s", rendered) + } +} + +func TestGenerateMaintenanceWorkflow_LabelTriggers_Disabled(t *testing.T) { workflowDataList := []*WorkflowData{ { Name: "test-workflow", @@ -658,7 +684,7 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Disabled(t *testing.T) tmpDir := t.TempDir() falseVal := false cfg := &RepoConfig{ - Maintenance: &MaintenanceConfig{LabelTriggerDisable: &falseVal}, + Maintenance: &MaintenanceConfig{LabelTriggers: &falseVal}, } err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { @@ -670,21 +696,28 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Disabled(t *testing.T) } yaml := string(content) - // Label-event triggers should be absent + // Label-event trigger should be absent if strings.Contains(yaml, " issues:\n types: [labeled]") { - t.Error("When label_trigger_disable is false the issues labeled trigger should not be present") + t.Error("When label_triggers is false the issues labeled trigger should not be present") } + + // The pull_request labeled trigger should never be present (removed) if strings.Contains(yaml, " pull_request:\n types: [labeled]") { - t.Error("When label_trigger_disable is false the pull_request labeled trigger should not be present") + t.Error("pull_request labeled trigger should never be present (issues-only)") } // The disable_agentic_workflow job should be absent if strings.Contains(yaml, "disable_agentic_workflow:") { - t.Error("When label_trigger_disable is false the disable_agentic_workflow job should not be present") + t.Error("When label_triggers is false the disable_agentic_workflow job should not be present") + } + + // The label_apply_safe_outputs job should be absent + if strings.Contains(yaml, "label_apply_safe_outputs:") { + t.Error("When label_triggers is false the label_apply_safe_outputs job should not be present") } } -func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Default(t *testing.T) { +func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { workflowDataList := []*WorkflowData{ { Name: "test-workflow", @@ -695,7 +728,7 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Default(t *testing.T) { } tmpDir := t.TempDir() - // Default: LabelTriggerDisable is nil (omitted) → treated as true → job included + // Default: LabelTriggers is nil (omitted) → treated as true → jobs included err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) @@ -706,18 +739,25 @@ func TestGenerateMaintenanceWorkflow_DisableLabelTrigger_Default(t *testing.T) { } yaml := string(content) - // Label-event triggers should be present by default + // Issues labeled trigger should be present by default if !strings.Contains(yaml, " issues:\n types: [labeled]") { t.Error("By default (no config) the issues labeled trigger should be present") } - if !strings.Contains(yaml, " pull_request:\n types: [labeled]") { - t.Error("By default (no config) the pull_request labeled trigger should be present") + + // pull_request labeled trigger should never be present (issues-only by design) + if strings.Contains(yaml, " pull_request:\n types: [labeled]") { + t.Error("pull_request labeled trigger should never be present (issues-only)") } // The disable_agentic_workflow job should be present by default if !strings.Contains(yaml, "disable_agentic_workflow:") { t.Error("By default (no config) the disable_agentic_workflow job should be present") } + + // The label_apply_safe_outputs job should be present by default + if !strings.Contains(yaml, "label_apply_safe_outputs:") { + t.Error("By default (no config) the label_apply_safe_outputs job should be present") + } } func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 31bddd3173b..5a7608d27e7 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -58,12 +58,10 @@ on: `) } - // Add label-event triggers only when the label-triggered disable job is enabled + // Add label-event trigger only when the label-triggered jobs are enabled if !disableLabelTrigger { yaml.WriteString(` issues: types: [labeled] - pull_request: - types: [labeled] `) } @@ -627,7 +625,7 @@ jobs: // Add disable_agentic_workflow job triggered by label "agentic-workflows:disable" on issues or PRs. // This job reads the body of the labeled issue/PR to extract the workflow_id from XML comment // markers, disables the corresponding agentic workflow, and posts a confirmation comment. - // Skipped when disable_label_trigger is set to true in aw.json maintenance config. + // Skipped when label_triggers is set to false in aw.json maintenance config. if !disableLabelTrigger { disableLabelCondition := buildLabeledDisableCondition() yaml.WriteString(` @@ -638,7 +636,6 @@ jobs: actions: write contents: read issues: write - pull-requests: write steps: - name: Checkout repository uses: ` + getActionPin("actions/checkout") + ` @@ -676,6 +673,55 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); await main(); `) + + // Add label_apply_safe_outputs job triggered by "agentic-workflows:apply-safe-outputs" label on issues. + // This job extracts a workflow run URL from the issue body XML comments and re-applies the safe outputs. + applySafeOutputsCondition := buildLabeledApplySafeOutputsCondition() + yaml.WriteString(` + label_apply_safe_outputs: + if: ${{ ` + RenderCondition(applySafeOutputsCondition) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + steps: + - name: Checkout actions folder + uses: ` + getActionPin("actions/checkout") + ` + with: + sparse-checkout: | + actions + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Apply safe outputs from referenced run + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/label_apply_safe_outputs.cjs'); + await main(); +`) } // Add compile-workflows and zizmor-scan jobs only in dev mode diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 684edf55d0c..76ea65da929 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -10,7 +10,7 @@ // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72 // expiration (hours) for conclusion failure issues -// "label_trigger_disable": false // set to false to disable the label-triggered disable job +// "label_triggers": false // set to false to disable all label-triggered jobs // } // maintenance jobs (default: ubuntu-slim) // } // @@ -74,20 +74,21 @@ type MaintenanceConfig struct { // failure issues opened by the conclusion job. Defaults to 168 (7 days). ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` - // LabelTriggerDisable controls the label-triggered disable_agentic_workflow job. - // The value represents whether the feature is active: true (or omitted/nil) means - // the job IS included; false means the job is excluded. - // To opt out, set label_trigger_disable: false in aw.json. - LabelTriggerDisable *bool `json:"label_trigger_disable,omitempty"` + // LabelTriggers controls all label-triggered jobs (disable_agentic_workflow, + // label_apply_safe_outputs, etc.). + // The value is treated as a feature-active flag: true (or omitted/nil) means + // all label-triggered jobs ARE included; false explicitly opts out. + // To opt out, set label_triggers: false in aw.json. + LabelTriggers *bool `json:"label_triggers,omitempty"` } -// IsLabelTriggerEnabled returns true unless label_trigger_disable is explicitly set to false. +// IsLabelTriggerEnabled returns true unless label_triggers is explicitly set to false. // The default (nil / omitted) is treated as enabled (true). func (m *MaintenanceConfig) IsLabelTriggerEnabled() bool { - if m == nil || m.LabelTriggerDisable == nil { + if m == nil || m.LabelTriggers == nil { return true } - return *m.LabelTriggerDisable + return *m.LabelTriggers } // RepoConfig is the parsed representation of aw.json. diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 84c587e754f..32cc3cd6a09 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -100,39 +100,39 @@ func TestLoadRepoConfig_SchemaViolation(t *testing.T) { assert.Error(t, err, "schema violation should return an error") } -func TestLoadRepoConfig_DisableLabelTrigger(t *testing.T) { +func TestLoadRepoConfig_LabelTriggersDisable(t *testing.T) { dir := t.TempDir() - writeAWJSON(t, dir, `{"maintenance": {"label_trigger_disable": false}}`) + writeAWJSON(t, dir, `{"maintenance": {"label_triggers": false}}`) 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") - require.NotNil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be set") - assert.False(t, *cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be false when explicitly set") - assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "setting label_trigger_disable: false explicitly opts out — the job should not be included") + require.NotNil(t, cfg.Maintenance.LabelTriggers, "label_triggers should be set") + assert.False(t, *cfg.Maintenance.LabelTriggers, "label_triggers should be false when explicitly set") + assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "setting label_triggers: false explicitly opts out — label-triggered jobs should not be included") } -func TestLoadRepoConfig_LabelTriggerDisable_DefaultTrue(t *testing.T) { +func TestLoadRepoConfig_LabelTriggers_DefaultTrue(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"maintenance": {}}`) 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.Nil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be nil when not specified") - assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label trigger should be enabled by default (nil = true)") + assert.Nil(t, cfg.Maintenance.LabelTriggers, "label_triggers should be nil when not specified") + assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label triggers should be enabled by default (nil = true)") } -func TestLoadRepoConfig_LabelTriggerDisable_ExplicitTrue(t *testing.T) { +func TestLoadRepoConfig_LabelTriggers_ExplicitTrue(t *testing.T) { dir := t.TempDir() - writeAWJSON(t, dir, `{"maintenance": {"label_trigger_disable": true}}`) + writeAWJSON(t, dir, `{"maintenance": {"label_triggers": true}}`) 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") - require.NotNil(t, cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be set") - assert.True(t, *cfg.Maintenance.LabelTriggerDisable, "label_trigger_disable should be true") - assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label_trigger_disable: true keeps the job enabled (true = feature active, despite the 'disable' suffix in the field name)") + require.NotNil(t, cfg.Maintenance.LabelTriggers, "label_triggers should be set") + assert.True(t, *cfg.Maintenance.LabelTriggers, "label_triggers should be true when explicitly set") + assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label_triggers: true keeps label-triggered jobs enabled") } // TestLoadRepoConfig_UnknownProperty tests that unknown properties are rejected. From 17ab2ae1f9c9c7920b079fa6d255f7126abe39f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:15:24 +0000 Subject: [PATCH 10/15] fix: improve comment wording in test files per code review Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0f3f9c0f-0fc7-41b4-9151-a7011a4cc667 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/label_apply_safe_outputs.test.cjs | 2 +- pkg/workflow/maintenance_workflow_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/label_apply_safe_outputs.test.cjs b/actions/setup/js/label_apply_safe_outputs.test.cjs index 1019971ae9c..fc1a56907af 100644 --- a/actions/setup/js/label_apply_safe_outputs.test.cjs +++ b/actions/setup/js/label_apply_safe_outputs.test.cjs @@ -216,7 +216,7 @@ describe("main", () => { const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); - // The issue body has no run URL, so main() will call setFailed after ensureLabel — + // The issue body has no run URL, so main() will call setFailed after ensureApplySafeOutputsLabelExists — // we only care that createLabel was called with the right args. global.context = { ...global.context, diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index eec38ca709d..f32eda11d7a 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -563,7 +563,7 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { const jobSectionSearchRange = 2000 - // Verify only the issues label trigger is present (pull_request is no longer supported) + // Verify only the issues label trigger is present (pull request is no longer supported) if !strings.Contains(yaml, " issues:\n types: [labeled]") { t.Error("Maintenance workflow should include issues: types: [labeled] trigger") } From a6324c3a14ad17671fdebf7f04545999bc3ccc4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:46:26 +0000 Subject: [PATCH 11/15] refactor: rename disable job to label_..., use REST API, extract shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename job disable_agentic_workflow → label_disable_agentic_workflow (matches label_apply_safe_outputs naming pattern) - Use github.rest.actions.disableWorkflow() REST API directly instead of spawning a gh aw disable subprocess; removes the CLI install steps from that job and the need for GH_TOKEN / GH_AW_CMD_PREFIX env vars - Extract label_trigger_helpers.cjs with three shared helpers: ensureLabelExists(owner, repo, name, color, description) validateLabeledIssueEvent(expectedLabel) → { owner, repo, issueNumber, body } | null removeLabelSafely(owner, repo, issueNumber, labelName) - Refactor disable_agentic_workflow.cjs and label_apply_safe_outputs.cjs to use the shared helpers (remove ensureDisableLabelExists, ensureApplySafeOutputsLabelExists, the inline event-check/payload-read/label-check boilerplate, and the try/catch label removal blocks from each file) - Update tests: label_trigger_helpers.test.cjs (11 new tests for shared helpers) disable_agentic_workflow.test.cjs updated: uses actions.disableWorkflow mock instead of exec.exec; removes ensureDisableLabelExists describe block label_apply_safe_outputs.test.cjs updated: removes ensureApplySafeOutputsLabelExists describe block (now covered by label_trigger_helpers.test.cjs) - Update Go tests: new job name, bounded section search to avoid spilling into next job, updated setup-go pin count (5 instead of 6) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bde59209-e7f2-4f7b-bbc1-6e0d5ab45c7b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 18 +- actions/setup/js/disable_agentic_workflow.cjs | 147 ++----------- .../js/disable_agentic_workflow.test.cjs | 82 ++------ actions/setup/js/label_apply_safe_outputs.cjs | 76 +------ .../js/label_apply_safe_outputs.test.cjs | 51 +---- actions/setup/js/label_trigger_helpers.cjs | 88 ++++++++ .../setup/js/label_trigger_helpers.test.cjs | 199 ++++++++++++++++++ .../docs/reference/frontmatter-full.md | 191 ++++++++++++++--- pkg/workflow/maintenance_workflow_test.go | 56 ++--- pkg/workflow/maintenance_workflow_yaml.go | 21 +- 10 files changed, 543 insertions(+), 386 deletions(-) create mode 100644 actions/setup/js/label_trigger_helpers.cjs create mode 100644 actions/setup/js/label_trigger_helpers.test.cjs diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 54544e7777c..5378c14479f 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -556,7 +556,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); await main(); - disable_agentic_workflow: + label_disable_agentic_workflow: if: ${{ (!(github.event.repository.fork)) && github.event_name == 'issues' && github.event.label.name == 'agentic-workflows:disable' }} runs-on: ubuntu-slim permissions: @@ -564,9 +564,11 @@ jobs: contents: read issues: write steps: - - name: Checkout repository + - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + sparse-checkout: | + actions persist-credentials: false - name: Setup Scripts @@ -584,20 +586,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); await main(); - - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Build gh-aw - run: make build - - name: Disable agentic workflow uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_CMD_PREFIX: ./gh-aw with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index e8d1ff316dd..9d57f28f5c4 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -3,7 +3,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_NOT_FOUND } = require("./error_codes.cjs"); -const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); +const { ensureLabelExists, validateLabeledIssueEvent, removeLabelSafely } = require("./label_trigger_helpers.cjs"); const DISABLE_LABEL = "agentic-workflows:disable"; const DISABLE_LABEL_COLOR = "8250df"; // GitHub purple @@ -69,186 +69,85 @@ function extractWorkflowId(body) { return null; } -/** - * Ensure the "agentic-workflows:disable" label exists in the repository. - * Creates it with the standard purple color if it is missing. - * This is a no-op (and non-fatal) when the label already exists. - * - * @param {string} owner - * @param {string} repo - * @returns {Promise} - */ -async function ensureDisableLabelExists(owner, repo) { - try { - await github.rest.issues.createLabel({ - owner, - repo, - name: DISABLE_LABEL, - color: DISABLE_LABEL_COLOR, - description: DISABLE_LABEL_DESCRIPTION, - }); - core.info(`✅ Created label '${DISABLE_LABEL}'`); - } catch (err) { - // 422 means the label already exists — expected on most runs - if (err !== null && typeof err === "object" && /** @type {any} */ err.status === 422) { - core.info(`ℹ️ Label '${DISABLE_LABEL}' already exists`); - } else { - // Non-fatal: log a warning but continue — the label may already be present - core.warning(`Failed to ensure label '${DISABLE_LABEL}' exists: ${getErrorMessage(err)}`); - } - } -} - /** * Disable an agentic workflow when the "agentic-workflows:disable" label is applied to an issue. * * Reads the labeled issue body to extract the workflow_id from XML comment markers, - * disables the corresponding agentic workflow using `gh aw disable`, and posts a comment + * disables the corresponding agentic workflow via the GitHub REST API, and posts a comment * confirming the action. * * @returns {Promise} */ async function main() { - const eventName = context.eventName; - if (eventName !== "issues") { - core.info(`Skipping: unexpected event type '${eventName}' (expected 'issues')`); - return; - } + const ctx = validateLabeledIssueEvent(DISABLE_LABEL); + if (!ctx) return; - const { owner, repo } = resolveExecutionOwnerRepo(); + const { owner, repo, issueNumber, body } = ctx; // Ensure the disable label exists so it is available for future use - await ensureDisableLabelExists(owner, repo); - - // Get the issue from the payload - const item = context.payload.issue; - if (!item) { - core.warning("No issue found in event payload"); - return; - } - - const itemNumber = item.number; - const labelName = context.payload.label?.name; - - if (labelName !== DISABLE_LABEL) { - core.info(`Skipping: label '${labelName}' is not '${DISABLE_LABEL}'`); - return; - } + await ensureLabelExists(owner, repo, DISABLE_LABEL, DISABLE_LABEL_COLOR, DISABLE_LABEL_DESCRIPTION); - core.info(`Processing issue #${itemNumber} labeled with '${labelName}'`); + core.info(`Processing issue #${issueNumber} labeled with '${DISABLE_LABEL}'`); // Extract workflow ID from body XML comment markers - const body = item.body || ""; const workflowId = extractWorkflowId(body); if (!workflowId) { - core.warning(`Could not find workflow ID in issue #${itemNumber} body. Expected a marker.`); + core.warning(`Could not find workflow ID in issue #${issueNumber} body. Expected a marker.`); await github.rest.issues.createComment({ owner, repo, - issue_number: itemNumber, + issue_number: issueNumber, body: `> [!WARNING]\n` + `> **Could not disable agentic workflow**\n>\n` + `> No workflow ID marker was found in this issue's body. ` + `The \`${DISABLE_LABEL}\` label can only be used on issues that were created by an agentic workflow ` + `(they contain a \`\` marker).\n>\n` + - `> To disable a workflow manually, use:\n` + - `> \`\`\`\n` + - `> gh aw disable \n` + - `> \`\`\``, + `> To disable a workflow manually, trigger the maintenance workflow with the \`disable\` operation.`, }); - core.setFailed(`${ERR_NOT_FOUND}: No workflow ID marker found in issue #${itemNumber}`); + core.setFailed(`${ERR_NOT_FOUND}: No workflow ID marker found in issue #${issueNumber}`); return; } core.info(`Found workflow ID: ${workflowId}`); - - // Disable the workflow using gh aw disable - const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; - const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean); - core.info(`Disabling agentic workflow '${workflowId}'...`); - let exitCode; + + // Disable the workflow via the GitHub REST API using its compiled lock file name + const lockFileName = `${workflowId}.lock.yml`; try { - exitCode = await exec.exec(bin, [...prefixArgs, "disable", workflowId], { - env: { - HOME: process.env.HOME || "", - PATH: process.env.PATH || "", - GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "", - GITHUB_TOKEN: process.env.GITHUB_TOKEN || "", - GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY || "", - GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL || "https://github.com", - GH_AW_CMD_PREFIX: cmdPrefixStr, - }, - ignoreReturnCode: true, - }); + await github.rest.actions.disableWorkflow({ owner, repo, workflow_id: lockFileName }); } catch (err) { const msg = getErrorMessage(err); - core.error(`Failed to run disable command: ${msg}`); + core.error(`Failed to disable workflow '${workflowId}': ${msg}`); await github.rest.issues.createComment({ owner, repo, - issue_number: itemNumber, + issue_number: issueNumber, body: `> [!WARNING]\n` + `> **Failed to disable agentic workflow \`${workflowId}\`**\n>\n` + - `> The disable command encountered an error: ${msg}\n>\n` + + `> ${msg}\n>\n` + `> Please check the [workflow run logs](${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID || ""}) for details.`, }); core.setFailed(`Failed to disable workflow '${workflowId}': ${msg}`); return; } - if (exitCode !== 0) { - const msg = `Command exited with code ${exitCode}`; - core.error(msg); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: itemNumber, - body: - `> [!WARNING]\n` + - `> **Failed to disable agentic workflow \`${workflowId}\`**\n>\n` + - `> The \`gh aw disable ${workflowId}\` command failed (exit code ${exitCode}).\n>\n` + - `> Please check the [workflow run logs](${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID || ""}) for details.`, - }); - core.setFailed(`gh aw disable '${workflowId}' failed with exit code ${exitCode}`); - return; - } - core.info(`Successfully disabled workflow '${workflowId}'`); // Post a success comment on the issue await github.rest.issues.createComment({ owner, repo, - issue_number: itemNumber, - body: - `The agentic workflow \`${workflowId}\` has been disabled.\n\n` + - `To re-enable it, use:\n` + - `\`\`\`\n` + - `gh aw enable ${workflowId}\n` + - `\`\`\`\n\n` + - `Or trigger the maintenance workflow with the \`enable\` operation.\n\n` + - ``, + issue_number: issueNumber, + body: `The agentic workflow \`${workflowId}\` has been disabled.\n\n` + `To re-enable it, trigger the maintenance workflow with the \`enable\` operation.\n\n` + ``, }); - core.info(`Posted disable confirmation comment on issue #${itemNumber}`); + core.info(`Posted disable confirmation comment on issue #${issueNumber}`); // Remove the disable label now that the action is complete - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: itemNumber, - name: DISABLE_LABEL, - }); - core.info(`Removed label '${DISABLE_LABEL}' from issue #${itemNumber}`); - } catch (err) { - // Non-fatal: the disable already succeeded, just log a warning - core.warning(`Failed to remove label '${DISABLE_LABEL}': ${getErrorMessage(err)}`); - } + await removeLabelSafely(owner, repo, issueNumber, DISABLE_LABEL); } -module.exports = { main, extractWorkflowId, isValidWorkflowId, ensureDisableLabelExists }; +module.exports = { main, extractWorkflowId, isValidWorkflowId }; diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs index e349d71e034..a9468185ba8 100644 --- a/actions/setup/js/disable_agentic_workflow.test.cjs +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { extractWorkflowId, ensureDisableLabelExists, main } = req("./disable_agentic_workflow.cjs"); +const { extractWorkflowId, isValidWorkflowId, main } = req("./disable_agentic_workflow.cjs"); // ─── global mocks ──────────────────────────────────────────────────────────── @@ -15,12 +15,11 @@ const mockCore = { setFailed: vi.fn(), }; -const mockExec = { - exec: vi.fn(), -}; - const mockGithub = { rest: { + actions: { + disableWorkflow: vi.fn(), + }, issues: { createComment: vi.fn(), removeLabel: vi.fn(), @@ -39,22 +38,20 @@ const mockContext = { }; global.core = mockCore; -global.exec = mockExec; global.github = mockGithub; global.context = mockContext; describe("main", () => { beforeEach(() => { vi.clearAllMocks(); - process.env.GH_AW_CMD_PREFIX = "./gh-aw"; process.env.GH_TOKEN = "fake-token"; process.env.GITHUB_TOKEN = "fake-token"; process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; process.env.GITHUB_SERVER_URL = "https://github.com"; process.env.GITHUB_RUN_ID = "999"; - // Default: disable command succeeds - mockExec.exec.mockResolvedValue(0); + // Default: REST API disable call succeeds + mockGithub.rest.actions.disableWorkflow.mockResolvedValue({}); mockGithub.rest.issues.createComment.mockResolvedValue({}); mockGithub.rest.issues.removeLabel.mockResolvedValue({}); // Default: createLabel returns 422 (label already exists) @@ -72,10 +69,14 @@ describe("main", () => { }; }); - it("disables the workflow and posts a success comment", async () => { + it("disables the workflow via REST API and posts a success comment", async () => { await main(); - expect(mockExec.exec).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(["disable", "my-workflow"]), expect.objectContaining({ ignoreReturnCode: true })); + expect(mockGithub.rest.actions.disableWorkflow).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + workflow_id: "my-workflow.lock.yml", + }); expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( expect.objectContaining({ owner: "test-owner", @@ -111,7 +112,7 @@ describe("main", () => { await main(); - expect(mockExec.exec).not.toHaveBeenCalled(); + expect(mockGithub.rest.actions.disableWorkflow).not.toHaveBeenCalled(); expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); }); @@ -130,12 +131,13 @@ describe("main", () => { expect(mockGithub.rest.issues.removeLabel).not.toHaveBeenCalled(); }); - it("does not remove label when the disable command fails", async () => { - mockExec.exec.mockResolvedValue(1); // non-zero exit + it("does not remove label when the REST API disable call fails", async () => { + mockGithub.rest.actions.disableWorkflow.mockRejectedValue(new Error("Forbidden")); await main(); expect(mockGithub.rest.issues.removeLabel).not.toHaveBeenCalled(); + expect(mockCore.setFailed).toHaveBeenCalled(); }); it("logs a warning when label removal fails but does not fail the step", async () => { @@ -162,8 +164,7 @@ describe("main", () => { expect(mockCore.setFailed).toHaveBeenCalled(); }); - it("calls ensureDisableLabelExists (createLabel) at the start of main", async () => { - // createLabel returning 422 (already exists) is the happy path + it("calls ensureLabelExists (createLabel) at the start of main", async () => { const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); @@ -177,57 +178,14 @@ describe("main", () => { ); }); - it("continues normally when ensureDisableLabelExists creates the label (201)", async () => { - // Label didn't exist yet — createLabel succeeds + it("continues normally when ensureLabelExists creates the label (201)", async () => { + // Label did not exist yet — createLabel succeeds mockGithub.rest.issues.createLabel.mockResolvedValue({}); await main(); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label 'agentic-workflows:disable'")); - expect(mockExec.exec).toHaveBeenCalled(); // disable command still ran - }); -}); - -describe("ensureDisableLabelExists", () => { - beforeEach(() => { - vi.clearAllMocks(); - global.github = mockGithub; - global.core = mockCore; - }); - - it("creates the label when it does not exist", async () => { - mockGithub.rest.issues.createLabel.mockResolvedValue({}); - - await ensureDisableLabelExists("owner", "repo"); - - expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( - expect.objectContaining({ - owner: "owner", - repo: "repo", - name: "agentic-workflows:disable", - color: "8250df", - }) - ); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label")); - }); - - it("treats a 422 response as 'already exists' and does not warn", async () => { - const err = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); - mockGithub.rest.issues.createLabel.mockRejectedValue(err); - - await ensureDisableLabelExists("owner", "repo"); - - expect(mockCore.warning).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists")); - }); - - it("logs a warning on non-422 errors but does not throw", async () => { - const err = Object.assign(new Error("Internal Server Error"), { status: 500 }); - mockGithub.rest.issues.createLabel.mockRejectedValue(err); - - await ensureDisableLabelExists("owner", "repo"); - - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to ensure label")); + expect(mockGithub.rest.actions.disableWorkflow).toHaveBeenCalled(); // disable still ran }); }); diff --git a/actions/setup/js/label_apply_safe_outputs.cjs b/actions/setup/js/label_apply_safe_outputs.cjs index 35a78998a3e..5fbe57db83e 100644 --- a/actions/setup/js/label_apply_safe_outputs.cjs +++ b/actions/setup/js/label_apply_safe_outputs.cjs @@ -3,7 +3,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_NOT_FOUND } = require("./error_codes.cjs"); -const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); +const { ensureLabelExists, validateLabeledIssueEvent, removeLabelSafely } = require("./label_trigger_helpers.cjs"); const APPLY_SAFE_OUTPUTS_LABEL = "agentic-workflows:apply-safe-outputs"; const APPLY_SAFE_OUTPUTS_LABEL_COLOR = "8250df"; // GitHub purple @@ -44,36 +44,6 @@ function extractRunUrl(body) { return null; } -/** - * Ensure the "agentic-workflows:apply-safe-outputs" label exists in the repository. - * Creates it with the standard purple color if it is missing. - * This is a no-op (and non-fatal) when the label already exists. - * - * @param {string} owner - * @param {string} repo - * @returns {Promise} - */ -async function ensureApplySafeOutputsLabelExists(owner, repo) { - try { - await github.rest.issues.createLabel({ - owner, - repo, - name: APPLY_SAFE_OUTPUTS_LABEL, - color: APPLY_SAFE_OUTPUTS_LABEL_COLOR, - description: APPLY_SAFE_OUTPUTS_LABEL_DESCRIPTION, - }); - core.info(`✅ Created label '${APPLY_SAFE_OUTPUTS_LABEL}'`); - } catch (err) { - // 422 means the label already exists — expected on most runs - if (err !== null && typeof err === "object" && /** @type {any} */ err.status === 422) { - core.info(`ℹ️ Label '${APPLY_SAFE_OUTPUTS_LABEL}' already exists`); - } else { - // Non-fatal: log a warning but continue — the label may already be present - core.warning(`Failed to ensure label '${APPLY_SAFE_OUTPUTS_LABEL}' exists: ${getErrorMessage(err)}`); - } - } -} - /** * Re-apply safe outputs from a previous workflow run when the * "agentic-workflows:apply-safe-outputs" label is applied to an issue. @@ -85,36 +55,17 @@ async function ensureApplySafeOutputsLabelExists(owner, repo) { * @returns {Promise} */ async function main() { - const eventName = context.eventName; - if (eventName !== "issues") { - core.info(`Skipping: unexpected event type '${eventName}' (expected 'issues')`); - return; - } + const ctx = validateLabeledIssueEvent(APPLY_SAFE_OUTPUTS_LABEL); + if (!ctx) return; - const { owner, repo } = resolveExecutionOwnerRepo(); + const { owner, repo, issueNumber, body } = ctx; // Ensure the label exists so it is available for future use - await ensureApplySafeOutputsLabelExists(owner, repo); + await ensureLabelExists(owner, repo, APPLY_SAFE_OUTPUTS_LABEL, APPLY_SAFE_OUTPUTS_LABEL_COLOR, APPLY_SAFE_OUTPUTS_LABEL_DESCRIPTION); - // Get the issue from the payload - const item = context.payload.issue; - if (!item) { - core.warning("No issue found in event payload"); - return; - } - - const issueNumber = item.number; - const labelName = context.payload.label?.name; - - if (labelName !== APPLY_SAFE_OUTPUTS_LABEL) { - core.info(`Skipping: label '${labelName}' is not '${APPLY_SAFE_OUTPUTS_LABEL}'`); - return; - } - - core.info(`Processing issue #${issueNumber} labeled with '${labelName}'`); + core.info(`Processing issue #${issueNumber} labeled with '${APPLY_SAFE_OUTPUTS_LABEL}'`); // Extract run URL from body XML comment markers - const body = item.body || ""; const runUrl = extractRunUrl(body); if (!runUrl) { @@ -174,18 +125,7 @@ async function main() { core.info(`Posted success comment on issue #${issueNumber}`); // Remove the label now that the action is complete - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: issueNumber, - name: APPLY_SAFE_OUTPUTS_LABEL, - }); - core.info(`Removed label '${APPLY_SAFE_OUTPUTS_LABEL}' from issue #${issueNumber}`); - } catch (err) { - // Non-fatal: the apply already succeeded, just log a warning - core.warning(`Failed to remove label '${APPLY_SAFE_OUTPUTS_LABEL}': ${getErrorMessage(err)}`); - } + await removeLabelSafely(owner, repo, issueNumber, APPLY_SAFE_OUTPUTS_LABEL); } -module.exports = { main, extractRunUrl, ensureApplySafeOutputsLabelExists }; +module.exports = { main, extractRunUrl }; diff --git a/actions/setup/js/label_apply_safe_outputs.test.cjs b/actions/setup/js/label_apply_safe_outputs.test.cjs index fc1a56907af..087f296985d 100644 --- a/actions/setup/js/label_apply_safe_outputs.test.cjs +++ b/actions/setup/js/label_apply_safe_outputs.test.cjs @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { extractRunUrl, ensureApplySafeOutputsLabelExists, main } = req("./label_apply_safe_outputs.cjs"); +const { extractRunUrl, main } = req("./label_apply_safe_outputs.cjs"); // ─── global mocks ──────────────────────────────────────────────────────────── @@ -15,10 +15,6 @@ const mockCore = { setFailed: vi.fn(), }; -const mockExec = { - exec: vi.fn(), -}; - const mockGithub = { rest: { issues: { @@ -42,7 +38,6 @@ const mockContext = { }; global.core = mockCore; -global.exec = mockExec; global.github = mockGithub; global.context = mockContext; @@ -97,48 +92,6 @@ describe("extractRunUrl", () => { }); }); -// ─── ensureApplySafeOutputsLabelExists ─────────────────────────────────────── - -describe("ensureApplySafeOutputsLabelExists", () => { - beforeEach(() => { - vi.clearAllMocks(); - global.github = mockGithub; - global.core = mockCore; - }); - - it("creates the label when it does not exist", async () => { - mockGithub.rest.issues.createLabel.mockResolvedValue({}); - - await ensureApplySafeOutputsLabelExists("owner", "repo"); - - expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( - expect.objectContaining({ - name: "agentic-workflows:apply-safe-outputs", - color: "8250df", - }) - ); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label")); - }); - - it("logs info when label already exists (422)", async () => { - const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); - mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); - - await ensureApplySafeOutputsLabelExists("owner", "repo"); - - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists")); - expect(mockCore.warning).not.toHaveBeenCalled(); - }); - - it("logs a warning for unexpected errors (non-fatal)", async () => { - mockGithub.rest.issues.createLabel.mockRejectedValue(new Error("Network error")); - - await ensureApplySafeOutputsLabelExists("owner", "repo"); - - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to ensure label")); - }); -}); - // ─── main ───────────────────────────────────────────────────────────────────── describe("main", () => { @@ -216,7 +169,7 @@ describe("main", () => { const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); - // The issue body has no run URL, so main() will call setFailed after ensureApplySafeOutputsLabelExists — + // The issue body has no run URL, so main() will call setFailed after ensureLabelExists — // we only care that createLabel was called with the right args. global.context = { ...global.context, diff --git a/actions/setup/js/label_trigger_helpers.cjs b/actions/setup/js/label_trigger_helpers.cjs new file mode 100644 index 00000000000..b88fdaa0d05 --- /dev/null +++ b/actions/setup/js/label_trigger_helpers.cjs @@ -0,0 +1,88 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); + +/** + * Ensures a label exists in the repository, creating it if necessary. + * A 422 response means the label already exists (expected on most runs). + * Other errors are non-fatal and logged as warnings. + * + * @param {string} owner + * @param {string} repo + * @param {string} name - Label name + * @param {string} color - Hex color without '#' (e.g. "8250df") + * @param {string} description - Short label description + * @returns {Promise} + */ +async function ensureLabelExists(owner, repo, name, color, description) { + try { + await github.rest.issues.createLabel({ owner, repo, name, color, description }); + core.info(`✅ Created label '${name}'`); + } catch (err) { + if (err !== null && typeof err === "object" && /** @type {any} */ err.status === 422) { + core.info(`ℹ️ Label '${name}' already exists`); + } else { + core.warning(`Failed to ensure label '${name}' exists: ${getErrorMessage(err)}`); + } + } +} + +/** + * Validates that the current GitHub Actions event is an 'issues: labeled' event + * matching the given label. Resolves the owner/repo and reads the issue from the payload. + * + * Returns { owner, repo, issueNumber, body } on success, or null if the event + * should be silently skipped (wrong event type, missing payload, or wrong label). + * + * @param {string} expectedLabel - The label name to match + * @returns {{ owner: string, repo: string, issueNumber: number, body: string } | null} + */ +function validateLabeledIssueEvent(expectedLabel) { + const eventName = context.eventName; + if (eventName !== "issues") { + core.info(`Skipping: unexpected event type '${eventName}' (expected 'issues')`); + return null; + } + + const item = context.payload.issue; + if (!item) { + core.warning("No issue found in event payload"); + return null; + } + + const labelName = context.payload.label?.name; + if (labelName !== expectedLabel) { + core.info(`Skipping: label '${labelName}' is not '${expectedLabel}'`); + return null; + } + + const { owner, repo } = resolveExecutionOwnerRepo(); + return { + owner, + repo, + issueNumber: item.number, + body: item.body || "", + }; +} + +/** + * Removes a label from an issue. Non-fatal: logs a warning on failure. + * + * @param {string} owner + * @param {string} repo + * @param {number} issueNumber + * @param {string} labelName + * @returns {Promise} + */ +async function removeLabelSafely(owner, repo, issueNumber, labelName) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: labelName }); + core.info(`Removed label '${labelName}' from issue #${issueNumber}`); + } catch (err) { + core.warning(`Failed to remove label '${labelName}': ${getErrorMessage(err)}`); + } +} + +module.exports = { ensureLabelExists, validateLabeledIssueEvent, removeLabelSafely }; diff --git a/actions/setup/js/label_trigger_helpers.test.cjs b/actions/setup/js/label_trigger_helpers.test.cjs new file mode 100644 index 00000000000..dfb1577649b --- /dev/null +++ b/actions/setup/js/label_trigger_helpers.test.cjs @@ -0,0 +1,199 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { ensureLabelExists, validateLabeledIssueEvent, removeLabelSafely } = req("./label_trigger_helpers.cjs"); + +// ─── global mocks ──────────────────────────────────────────────────────────── + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), +}; + +const mockGithub = { + rest: { + issues: { + createLabel: vi.fn(), + removeLabel: vi.fn(), + createComment: vi.fn(), + }, + }, +}; + +global.core = mockCore; +global.github = mockGithub; + +// Default context — will be overridden per test where needed +global.context = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 42, body: "" }, + label: { name: "agentic-workflows:disable" }, + }, +}; + +// ─── ensureLabelExists ──────────────────────────────────────────────────────── + +describe("ensureLabelExists", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates the label when it does not exist", async () => { + mockGithub.rest.issues.createLabel.mockResolvedValue({}); + + await ensureLabelExists("owner", "repo", "my-label", "8250df", "My label description"); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + name: "my-label", + color: "8250df", + description: "My label description", + }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created label 'my-label'")); + }); + + it("logs info when label already exists (422)", async () => { + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); + + await ensureLabelExists("owner", "repo", "my-label", "8250df", "desc"); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists")); + expect(mockCore.warning).not.toHaveBeenCalled(); + }); + + it("logs a warning for unexpected errors (non-fatal)", async () => { + mockGithub.rest.issues.createLabel.mockRejectedValue(new Error("Network error")); + + await ensureLabelExists("owner", "repo", "my-label", "8250df", "desc"); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to ensure label 'my-label' exists")); + }); +}); + +// ─── validateLabeledIssueEvent ──────────────────────────────────────────────── + +describe("validateLabeledIssueEvent", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.context = { + eventName: "issues", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + issue: { number: 42, body: "my body" }, + label: { name: "agentic-workflows:disable" }, + }, + }; + }); + + it("returns issue context when event and label match", () => { + const result = validateLabeledIssueEvent("agentic-workflows:disable"); + + expect(result).not.toBeNull(); + expect(result?.owner).toBe("test-owner"); + expect(result?.repo).toBe("test-repo"); + expect(result?.issueNumber).toBe(42); + expect(result?.body).toBe("my body"); + }); + + it("returns null and logs info when event type is not issues", () => { + global.context = { ...global.context, eventName: "pull_request" }; + + const result = validateLabeledIssueEvent("agentic-workflows:disable"); + + expect(result).toBeNull(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping")); + }); + + it("returns null and logs warning when no issue in payload", () => { + global.context = { ...global.context, payload: { label: { name: "agentic-workflows:disable" } } }; + + const result = validateLabeledIssueEvent("agentic-workflows:disable"); + + expect(result).toBeNull(); + expect(mockCore.warning).toHaveBeenCalledWith("No issue found in event payload"); + }); + + it("returns null and logs info when label does not match", () => { + global.context = { + ...global.context, + payload: { + issue: { number: 42, body: "" }, + label: { name: "some-other-label" }, + }, + }; + + const result = validateLabeledIssueEvent("agentic-workflows:disable"); + + expect(result).toBeNull(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping")); + }); + + it("returns empty string body when issue body is null", () => { + global.context = { + ...global.context, + payload: { + issue: { number: 42, body: null }, + label: { name: "agentic-workflows:disable" }, + }, + }; + + const result = validateLabeledIssueEvent("agentic-workflows:disable"); + + expect(result).not.toBeNull(); + expect(result?.body).toBe(""); + }); + + it("works with apply-safe-outputs label too", () => { + global.context = { + ...global.context, + payload: { + issue: { number: 7, body: "some body" }, + label: { name: "agentic-workflows:apply-safe-outputs" }, + }, + }; + + const result = validateLabeledIssueEvent("agentic-workflows:apply-safe-outputs"); + + expect(result).not.toBeNull(); + expect(result?.issueNumber).toBe(7); + }); +}); + +// ─── removeLabelSafely ──────────────────────────────────────────────────────── + +describe("removeLabelSafely", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("removes the label and logs info", async () => { + mockGithub.rest.issues.removeLabel.mockResolvedValue({}); + + await removeLabelSafely("owner", "repo", 42, "my-label"); + + expect(mockGithub.rest.issues.removeLabel).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + issue_number: 42, + name: "my-label", + }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Removed label 'my-label' from issue #42")); + }); + + it("logs a warning when removal fails (non-fatal — does not throw)", async () => { + mockGithub.rest.issues.removeLabel.mockRejectedValue(new Error("Not Found")); + + await expect(removeLabelSafely("owner", "repo", 42, "my-label")).resolves.toBeUndefined(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to remove label 'my-label'")); + }); +}); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 31ad252b40f..55ac3b4a676 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -3383,16 +3383,33 @@ safe-outputs: # List of additional repositories in format 'owner/repo' that comments can be # created in. When specified, the agent can use a 'repo' field in the output to # specify which repository to create the comment in. The target repository - # (current or target-repo) is always implicitly allowed. + # (current or target-repo) is always implicitly allowed. Accepts an array or a + # GitHub Actions expression resolving to a comma-separated list (e.g. '${{ + # inputs[\'allowed-repos\'] }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of repository slugs in 'owner/repo' format allowed-repos: [] - # Array of strings + # Array items: string + + # Option 2: GitHub Actions expression resolving to a comma-separated list of + # repository slugs (e.g. '${{ inputs[\'allowed-repos\'] }}') + allowed-repos: "example-value" # When true, minimizes/hides all previous comments from the same agentic workflow - # (identified by tracker-id) before creating the new comment. Default: false. + # (identified by tracker-id) before creating the new comment. Supports literal + # boolean or GitHub Actions expression (e.g. '${{ inputs.hide-older-comments }}'). + # Default: false. # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: boolean hide-older-comments: true + # Option 2: GitHub Actions expression that resolves to a boolean at runtime + hide-older-comments: "example-value" + # List of allowed reasons for hiding older comments when hide-older-comments is # enabled. Default: all reasons allowed (spam, abuse, off_topic, outdated, # resolved, low_quality). @@ -3462,10 +3479,19 @@ safe-outputs: # (optional) title-prefix: "example-value" - # Optional list of labels to attach to the pull request + # Optional list of labels to attach to the pull request. Accepts an array of label + # names or a GitHub Actions expression resolving to a comma-separated list (e.g. + # '${{ inputs.labels }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of label names labels: [] - # Array of strings + # Array items: string + + # Option 2: GitHub Actions expression resolving to a comma-separated list of label + # names (e.g. '${{ inputs.labels }}') + labels: "example-value" # Optional list of allowed labels that can be used when creating pull requests. If # omitted, any labels are allowed (including creating new ones). When specified, @@ -3553,10 +3579,19 @@ safe-outputs: # List of additional repositories in format 'owner/repo' that pull requests can be # created in. When specified, the agent can use a 'repo' field in the output to # specify which repository to create the pull request in. The target repository - # (current or target-repo) is always implicitly allowed. + # (current or target-repo) is always implicitly allowed. Accepts an array or a + # GitHub Actions expression resolving to a comma-separated list (e.g. '${{ + # inputs[\'allowed-repos\'] }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of repository slugs in 'owner/repo' format allowed-repos: [] - # Array of strings + # Array items: string + + # Option 2: GitHub Actions expression resolving to a comma-separated list of + # repository slugs (e.g. '${{ inputs[\'allowed-repos\'] }}') + allowed-repos: "example-value" # GitHub token to use for this specific output type. Overrides global github-token # if specified. @@ -3591,10 +3626,19 @@ safe-outputs: # Optional list of allowed base branch patterns (glob syntax, e.g. 'main', # 'release/*'). When configured, the agent may provide a `base` field in # create_pull_request output to override base-branch for a single run, but only if - # it matches one of these patterns. + # it matches one of these patterns. Accepts an array or a GitHub Actions + # expression resolving to a comma-separated list (e.g. '${{ + # inputs[\'allowed-base-branches\'] }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of base branch patterns (glob syntax supported) allowed-base-branches: [] - # Array of strings + # Array items: string + + # Option 2: GitHub Actions expression resolving to a comma-separated list of base + # branch patterns (e.g. '${{ inputs[\'allowed-base-branches\'] }}') + allowed-base-branches: "example-value" # Controls whether AI-generated footer is added to the pull request. When false, # the visible footer content is omitted but XML markers (workflow-id, tracker-id, @@ -3627,8 +3671,8 @@ safe-outputs: github-token-for-extra-empty-commit: "example-value" # Controls protected-file protection. String form: blocked (default), allowed, or - # fallback-to-issue. Object form: { policy, exclude } to customise the - # protected-file set. + # fallback-to-issue — or a GitHub Actions expression for reusable workflows. + # Object form: { policy, exclude } to customise the protected-file set. # (optional) # This field supports multiple formats (oneOf): @@ -3639,16 +3683,27 @@ safe-outputs: # instead of a PR, so a human can review the manifest changes before merging. protected-files: "blocked" - # Option 2: Object form for granular control over the protected-file set. Use the + # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or + # 'fallback-to-issue' at runtime. Use in reusable workflow_call workflows to + # parameterise the policy per caller. + protected-files: "example-value" + + # Option 3: Object form for granular control over the protected-file set. Use the # exclude list to remove specific files from the default protection while keeping # the rest. protected-files: - # Protection policy. blocked (default): hard-block any patch that modifies - # protected files. allowed: allow all changes. fallback-to-issue: push the branch - # but create a review issue instead of a PR. # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Protection policy. blocked (default): hard-block any patch that + # modifies protected files. allowed: allow all changes. fallback-to-issue: push + # the branch but create a review issue instead of a PR. policy: "blocked" + # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or + # 'fallback-to-issue' at runtime. + policy: "example-value" + # List of filenames or path prefixes to remove from the default protected-file # set. Items are matched by basename (e.g. "AGENTS.md") or path prefix (e.g. # ".agents/"). Use this to allow the agent to modify specific files that are @@ -3698,11 +3753,21 @@ safe-outputs: # Array of strings # Transport format for packaging changes. "am" (default) uses git format-patch/git - # am. "bundle" uses git bundle, which preserves merge commit topology, per-commit - # authorship, and merge-resolution-only content. + # am. "bundle" uses git bundle. Accepts a GitHub Actions expression for reusable + # workflows. # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Transport format for packaging changes. "am" (default) uses git + # format-patch/git am. "bundle" uses git bundle, which preserves merge commit + # topology, per-commit authorship, and merge-resolution-only content. patch-format: "am" + # Option 2: GitHub Actions expression that resolves to 'am' or 'bundle' at + # runtime. Use in reusable workflow_call workflows to parameterise the transport + # format per caller. + patch-format: "example-value" + # If true, emit step summary messages instead of making GitHub API calls for this # specific output type (preview mode) # (optional) @@ -4758,10 +4823,19 @@ safe-outputs: title-prefix: "example-value" # Required labels for pull request validation. Only pull requests with all these - # labels will be accepted. + # labels will be accepted. Accepts an array of label names or a GitHub Actions + # expression resolving to a comma-separated list of labels (e.g. '${{ + # inputs[\'required-labels\'] }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of label names labels: [] - # Array of strings + # Array items: string + + # Option 2: GitHub Actions expression resolving to a comma-separated list of label + # names (e.g. '${{ inputs[\'required-labels\'] }}') + labels: "example-value" # Behavior when no changes to push: 'warn' (default - log warning but succeed), # 'error' (fail the action), or 'ignore' (silent success) @@ -4812,14 +4886,23 @@ safe-outputs: # List of additional repositories in format 'owner/repo' that push to pull request # branch can target. When specified, the agent can use a 'repo' field in the # output to specify which repository to push to. The target repository (current or - # target-repo) is always implicitly allowed. + # target-repo) is always implicitly allowed. Accepts an array or a GitHub Actions + # expression resolving to a comma-separated list (e.g. '${{ + # inputs[\'allowed-repos\'] }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of repository slugs in 'owner/repo' format allowed-repos: [] - # Array of strings + # Array items: string + + # Option 2: GitHub Actions expression resolving to a comma-separated list of + # repository slugs (e.g. '${{ inputs[\'allowed-repos\'] }}') + allowed-repos: "example-value" # Controls protected-file protection. String form: blocked (default), allowed, or - # fallback-to-issue. Object form: { policy, exclude } to customise the - # protected-file set. + # fallback-to-issue — or a GitHub Actions expression for reusable workflows. + # Object form: { policy, exclude } to customise the protected-file set. # (optional) # This field supports multiple formats (oneOf): @@ -4830,16 +4913,27 @@ safe-outputs: # PR branch, so a human can review the changes before applying. protected-files: "blocked" - # Option 2: Object form for granular control over the protected-file set. Use the + # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or + # 'fallback-to-issue' at runtime. Use in reusable workflow_call workflows to + # parameterise the policy per caller. + protected-files: "example-value" + + # Option 3: Object form for granular control over the protected-file set. Use the # exclude list to remove specific files from the default protection while keeping # the rest. protected-files: - # Protection policy. blocked (default): hard-block any patch that modifies - # protected files. allowed: allow all changes. fallback-to-issue: create a review - # issue instead of pushing. # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Protection policy. blocked (default): hard-block any patch that + # modifies protected files. allowed: allow all changes. fallback-to-issue: create + # a review issue instead of pushing. policy: "blocked" + # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or + # 'fallback-to-issue' at runtime. + policy: "example-value" + # List of filenames or path prefixes to remove from the default protected-file # set. Items are matched by basename (e.g. "AGENTS.md") or path prefix (e.g. # ".agents/"). Use this to allow the agent to modify specific files that are @@ -4872,11 +4966,21 @@ safe-outputs: # Array of strings # Transport format for packaging changes. "am" (default) uses git format-patch/git - # am. "bundle" uses git bundle, which preserves merge commit topology, per-commit - # authorship, and merge-resolution-only content. + # am. "bundle" uses git bundle. Accepts a GitHub Actions expression for reusable + # workflows. # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Transport format for packaging changes. "am" (default) uses git + # format-patch/git am. "bundle" uses git bundle, which preserves merge commit + # topology, per-commit authorship, and merge-resolution-only content. patch-format: "am" + # Option 2: GitHub Actions expression that resolves to 'am' or 'bundle' at + # runtime. Use in reusable workflow_call workflows to parameterise the transport + # format per caller. + patch-format: "example-value" + # When true, adds workflows: write to the GitHub App token permissions. Required # when allowed-files targets .github/workflows/ paths. Requires # safe-outputs.github-app to be configured because the workflows permission is a @@ -5109,10 +5213,18 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to an integer at runtime max: "example-value" - # Whether to create or update GitHub issues when tools are missing (default: true) + # Whether to create or update GitHub issues when tools are missing (default: + # true). Supports literal boolean or GitHub Actions expression (e.g. '${{ + # inputs.create-issue }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: boolean create-issue: true + # Option 2: GitHub Actions expression that resolves to a boolean at runtime + create-issue: "example-value" + # Prefix for issue titles when creating issues for missing tools (default: # '[missing tool]') # (optional) @@ -5160,10 +5272,18 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to an integer at runtime max: "example-value" - # Whether to create or update GitHub issues when data is missing (default: true) + # Whether to create or update GitHub issues when data is missing (default: true). + # Supports literal boolean or GitHub Actions expression (e.g. '${{ + # inputs.create-missing-data-issue }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: boolean create-issue: true + # Option 2: GitHub Actions expression that resolves to a boolean at runtime + create-issue: "example-value" + # Prefix for issue titles when creating issues for missing data (default: # '[missing data]') # (optional) @@ -5766,10 +5886,17 @@ safe-outputs: max: "example-value" # Whether to create or update GitHub issues when the task was incomplete (default: - # true) + # true). Supports literal boolean or GitHub Actions expression (e.g. '${{ + # inputs.create-incomplete-issue }}'). # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: boolean create-issue: true + # Option 2: GitHub Actions expression that resolves to a boolean at runtime + create-issue: "example-value" + # Prefix for issue titles when creating issues for incomplete runs (default: # '[incomplete]') # (optional) diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index f32eda11d7a..fb77fd27705 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -571,52 +571,57 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { t.Error("Maintenance workflow must NOT include pull_request: types: [labeled] trigger (issues-only)") } - // Verify the disable_agentic_workflow job exists - disableJobIdx := strings.Index(yaml, "\n disable_agentic_workflow:") + // Verify the label_disable_agentic_workflow job exists + disableJobIdx := strings.Index(yaml, "\n label_disable_agentic_workflow:") if disableJobIdx == -1 { - t.Fatal("Job disable_agentic_workflow not found in generated workflow") + t.Fatal("Job label_disable_agentic_workflow not found in generated workflow") } - disableJobSection := yaml[disableJobIdx : disableJobIdx+jobSectionSearchRange] + // Bound the section to just the label_disable_agentic_workflow job by finding the next job start + nextJobIdx := strings.Index(yaml[disableJobIdx+1:], "\n label_apply_safe_outputs:") + if nextJobIdx == -1 { + nextJobIdx = jobSectionSearchRange + } + disableJobSection := yaml[disableJobIdx : disableJobIdx+1+nextJobIdx] // Verify the condition triggers only on issues label events (not pull_request) if !strings.Contains(disableJobSection, "github.event_name == 'issues'") { - t.Errorf("disable_agentic_workflow job should trigger on issues events in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should trigger on issues events in:\n%s", disableJobSection) } if strings.Contains(disableJobSection, "github.event_name == 'pull_request'") { - t.Errorf("disable_agentic_workflow job must NOT trigger on pull_request events (issues-only) in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job must NOT trigger on pull_request events (issues-only) in:\n%s", disableJobSection) } if !strings.Contains(disableJobSection, "github.event.label.name == 'agentic-workflows:disable'") { - t.Errorf("disable_agentic_workflow job should check for agentic-workflows:disable label in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should check for agentic-workflows:disable label in:\n%s", disableJobSection) } if !strings.Contains(disableJobSection, "github.event.repository.fork") { - t.Errorf("disable_agentic_workflow job should exclude forks in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should exclude forks in:\n%s", disableJobSection) } // Verify required permissions (no pull-requests: write since issues-only) if !strings.Contains(disableJobSection, "actions: write") { - t.Errorf("disable_agentic_workflow job should have actions: write permission in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should have actions: write permission in:\n%s", disableJobSection) } if !strings.Contains(disableJobSection, "contents: read") { - t.Errorf("disable_agentic_workflow job should have contents: read permission in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should have contents: read permission in:\n%s", disableJobSection) } if strings.Contains(disableJobSection, "contents: write") { - t.Errorf("disable_agentic_workflow job must NOT have contents: write (only read is needed) in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job must NOT have contents: write (only read is needed) in:\n%s", disableJobSection) } if !strings.Contains(disableJobSection, "issues: write") { - t.Errorf("disable_agentic_workflow job should have issues: write permission in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should have issues: write permission in:\n%s", disableJobSection) } if strings.Contains(disableJobSection, "pull-requests: write") { - t.Errorf("disable_agentic_workflow job must NOT have pull-requests: write (issues-only) in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job must NOT have pull-requests: write (issues-only) in:\n%s", disableJobSection) } // Verify the job uses disable_agentic_workflow.cjs if !strings.Contains(disableJobSection, "disable_agentic_workflow.cjs") { - t.Errorf("disable_agentic_workflow job should use disable_agentic_workflow.cjs script in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should use disable_agentic_workflow.cjs script in:\n%s", disableJobSection) } // Verify the job includes the CLI installation and permission check steps if !strings.Contains(disableJobSection, "check_team_member.cjs") { - t.Errorf("disable_agentic_workflow job should check permissions using check_team_member.cjs in:\n%s", disableJobSection) + t.Errorf("label_disable_agentic_workflow job should check permissions using check_team_member.cjs in:\n%s", disableJobSection) } } @@ -706,9 +711,9 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Disabled(t *testing.T) { t.Error("pull_request labeled trigger should never be present (issues-only)") } - // The disable_agentic_workflow job should be absent - if strings.Contains(yaml, "disable_agentic_workflow:") { - t.Error("When label_triggers is false the disable_agentic_workflow job should not be present") + // The label_disable_agentic_workflow job should be absent + if strings.Contains(yaml, "label_disable_agentic_workflow:") { + t.Error("When label_triggers is false the label_disable_agentic_workflow job should not be present") } // The label_apply_safe_outputs job should be absent @@ -749,9 +754,9 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { t.Error("pull_request labeled trigger should never be present (issues-only)") } - // The disable_agentic_workflow job should be present by default - if !strings.Contains(yaml, "disable_agentic_workflow:") { - t.Error("By default (no config) the disable_agentic_workflow job should be present") + // The label_disable_agentic_workflow job should be present by default + if !strings.Contains(yaml, "label_disable_agentic_workflow:") { + t.Error("By default (no config) the label_disable_agentic_workflow job should be present") } // The label_apply_safe_outputs job should be present by default @@ -1114,12 +1119,13 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Fatalf("Expected maintenance workflow to be generated: %v", err) } yaml := string(content) - // run_operation, create_labels, activity_report, validate_workflows, disable_agentic_workflow, and compile_workflows should use the same setup-go version - // (all use getActionPin, not hardcoded pins). Exactly 6 occurrences expected. + // run_operation, create_labels, activity_report, validate_workflows, and compile_workflows should use the same setup-go version + // (all use getActionPin, not hardcoded pins). Exactly 5 occurrences expected. + // Note: label_disable_agentic_workflow no longer installs the CLI, so it has no setup-go step. setupGoPin := getActionPin("actions/setup-go") occurrences := strings.Count(yaml, setupGoPin) - if occurrences != 6 { - t.Errorf("Expected exactly 6 occurrences of pinned setup-go ref %q (run_operation + create_labels + activity_report + validate_workflows + disable_agentic_workflow + compile_workflows), got %d in:\n%s", + if occurrences != 5 { + t.Errorf("Expected exactly 5 occurrences of pinned setup-go ref %q (run_operation + create_labels + activity_report + validate_workflows + compile_workflows), got %d in:\n%s", setupGoPin, occurrences, yaml) } }) diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 5a7608d27e7..a80085428a2 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -622,14 +622,15 @@ jobs: await main(); `) - // Add disable_agentic_workflow job triggered by label "agentic-workflows:disable" on issues or PRs. - // This job reads the body of the labeled issue/PR to extract the workflow_id from XML comment - // markers, disables the corresponding agentic workflow, and posts a confirmation comment. + // Add label_disable_agentic_workflow job triggered by label "agentic-workflows:disable" on issues. + // This job reads the body of the labeled issue to extract the workflow_id from XML comment + // markers, disables the corresponding agentic workflow via the GitHub REST API, and posts + // a confirmation comment. // Skipped when label_triggers is set to false in aw.json maintenance config. if !disableLabelTrigger { disableLabelCondition := buildLabeledDisableCondition() yaml.WriteString(` - disable_agentic_workflow: + label_disable_agentic_workflow: if: ${{ ` + RenderCondition(disableLabelCondition) + ` }} runs-on: ` + runsOnValue + ` permissions: @@ -637,9 +638,11 @@ jobs: contents: read issues: write steps: - - name: Checkout repository + - name: Checkout actions folder uses: ` + getActionPin("actions/checkout") + ` with: + sparse-checkout: | + actions persist-credentials: false - name: Setup Scripts @@ -657,14 +660,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); await main(); -`) - - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Disable agentic workflow + - name: Disable agentic workflow uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From 930050b500dce5b1c118626afb92da48ddf6d500 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:11:44 +0000 Subject: [PATCH 12/15] docs(adr): add draft ADR-29269 for label-triggered maintenance jobs Co-Authored-By: Claude Sonnet 4.6 --- .../29269-label-triggered-maintenance-jobs.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/adr/29269-label-triggered-maintenance-jobs.md diff --git a/docs/adr/29269-label-triggered-maintenance-jobs.md b/docs/adr/29269-label-triggered-maintenance-jobs.md new file mode 100644 index 00000000000..ed8517844ea --- /dev/null +++ b/docs/adr/29269-label-triggered-maintenance-jobs.md @@ -0,0 +1,77 @@ +# ADR-29269: Label-Triggered Jobs for Agentic Maintenance Workflow + +**Date**: 2026-04-30 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +The agentic maintenance workflow previously lacked a way for maintainers to operationally control agentic workflows (disable them or replay their safe outputs) directly from the GitHub Issues UI. The only existing control paths required either command-line access or navigating the GitHub Actions `workflow_dispatch` interface — both of which are cumbersome for routine maintenance tasks. The system already tracks agentic workflow metadata (run URLs, workflow IDs) inside XML comment markers in issue bodies, making issues the natural locus for maintainer-initiated control actions. + +### Decision + +We will extend the agentic maintenance workflow with two label-triggered jobs: `label_disable_agentic_workflow` and `label_apply_safe_outputs`. When a maintainer with admin or maintainer permissions applies the `agentic-workflows:disable` or `agentic-workflows:apply-safe-outputs` label to an issue created by an agentic workflow, the corresponding job fires, reads the workflow metadata from the issue body's XML comment markers, and performs the requested operation via the GitHub REST API. Both jobs are controlled by a single `label_triggers` boolean flag in `aw.json` and default to enabled. We chose the GitHub Issues label mechanism because it is accessible from the standard GitHub UI, is auditable, and requires no additional tooling for the triggering party. + +### Alternatives Considered + +#### Alternative 1: `workflow_dispatch` manual operation inputs + +The workflow already supports a `workflow_dispatch` trigger with an `operation` input. Extending this to cover disable and replay operations was considered. It was rejected because it requires the operator to know the target workflow ID or run URL in advance, navigate to the Actions tab, and fill in a form — all steps that move away from the natural issue context where the metadata already lives. It also does not provide the same discoverability as a label applied to the relevant issue. + +#### Alternative 2: Slash commands in issue comments (`/disable-workflow`, `/apply-safe-outputs`) + +Slash commands parsed from issue comments are a common GitHub automation pattern. They were not chosen because the repository's existing automation stack uses labels as the primary trigger mechanism for maintainer actions, and adding a comment-parsing layer would require a separate event handler, introduce ambiguity around comment timing and re-entrancy, and increase the attack surface for command injection via crafted comment bodies. + +### Consequences + +#### Positive +- Maintainers can disable a misbehaving agentic workflow or replay its safe outputs directly from the issue UI without leaving GitHub. +- Labels are self-documenting and visible in issue timelines, making operational actions auditable by default. +- Shared helpers (`label_trigger_helpers.cjs`) encapsulate permission checking and label lifecycle, making future label-triggered jobs easier to add safely. +- The `label_triggers: false` opt-out in `aw.json` gives repo owners a single switch to remove both jobs from the generated workflow if the pattern is not wanted. + +#### Negative +- Adding `issues: [labeled]` to the workflow trigger causes the workflow to fire on every issue label event, even for issues unrelated to agentic workflows; the jobs' `if:` conditions short-circuit quickly, but there is a small cost in workflow invocation overhead. +- Each job requires a permission gate (`check_team_member.cjs`) as the first step; if that helper has a bug or the team membership API is unavailable, the jobs fail rather than degrading gracefully. +- Workflow IDs and run URLs embedded in issue bodies are relied upon as the source of truth; if an issue body is edited to remove or corrupt the XML comment markers, the jobs will fail silently with a "missing marker" comment rather than finding the data another way. + +#### Neutral +- The `agentic-workflows:disable` and `agentic-workflows:apply-safe-outputs` labels are created lazily on first job run rather than during general label setup (`create_labels.cjs`), which keeps label creation scoped to the feature but means the labels may not exist in a repository until the jobs run for the first time. +- The `label_disable_agentic_workflow` job uses only `contents: read` plus `actions: write`; the `label_apply_safe_outputs` job requires broader write permissions (`contents: write`, `pull-requests: write`, etc.) matching those needed by the safe-outputs replay logic. + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Label-Triggered Job Activation + +1. Label-triggered jobs **MUST** fire only on `issues: [labeled]` events, never on pull request events. +2. Each label-triggered job **MUST** verify that the actor holds admin or maintainer permissions before performing any mutation, using `check_team_member.cjs` or an equivalent gate. +3. Label-triggered jobs **MUST NOT** fire when `label_triggers` is set to `false` in the `maintenance` object of `aw.json`; the `issues: [labeled]` trigger and both jobs **MUST** be omitted from the generated workflow in that case. +4. Each job **MUST** remove the triggering label from the issue after a successful operation to prevent the job from re-running if the label is re-applied inadvertently. +5. Each job **SHOULD** post a comment to the issue describing the outcome (success, failure, or missing marker) so the triggering actor has immediate feedback. + +### Workflow Metadata Extraction + +1. Implementations **MUST** extract workflow identifiers exclusively from XML comment markers in the issue body (``, ``, or ``); they **MUST NOT** parse free-form issue body text. +2. Extracted workflow IDs **MUST** be validated against `isValidWorkflowId()` (alphanumeric characters, hyphens, underscores, and dots; maximum 100 characters; no `..` path segments) before use. +3. Implementations **MUST NOT** spread `process.env` into subprocess calls; only explicitly required environment variables **MAY** be passed to subprocesses. + +### Label Lifecycle + +1. The `agentic-workflows:disable` and `agentic-workflows:apply-safe-outputs` labels **MUST** be created idempotently by their respective jobs on first run using `ensureLabelExists()`; they **MUST NOT** be added to the general `create_labels` label set. +2. Label creation **MUST** be scoped exclusively to the job that uses the label; no cross-job label dependencies are permitted. + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Specifically: label-triggered jobs fire only on issue events; actor permissions are verified before any mutation; label-triggered jobs are omitted when `label_triggers: false`; workflow IDs are validated before use; subprocesses receive only explicitly scoped environment variables; and per-operation labels are created by their owning job rather than centrally. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25181104179) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* From 41d5f81d1fc9dd08a89d3208ad17ab79f626f760 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:00:08 +0000 Subject: [PATCH 13/15] fix: centralize workflow ID extraction, fix lockFileName normalization and minor fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move extractWorkflowId() + isValidWorkflowId() + normalizeWorkflowId() from disable_agentic_workflow.cjs to generate_footer.cjs where related marker utilities already live (matchesWorkflowId, generateWorkflowIdMarker, etc.) - Add extension normalization: any .yml, .yaml, or .lock.yml suffix is stripped from the extracted ID so the result is always a bare workflow identifier. This fixes the lockFileName bug where a marker like '' would produce 'my-workflow.yml.lock.yml' instead of 'my-workflow.lock.yml' - Fix label description: "issue or pull request" → "issue" (issues-only event) - Fix repo_config.go header comment: add missing trailing comma after action_failure_issue_expires: 72 so the JSON example is valid - Fix test comment: remove outdated "CLI installation" reference (job no longer installs the CLI) - Update disable_agentic_workflow.cjs to import extractWorkflowId/isValidWorkflowId from generate_footer.cjs instead of duplicating the definitions - Update disable_agentic_workflow.test.cjs to import from generate_footer.cjs - Add 10 new tests for .yml/.yaml/.lock.yml stripping and isValidWorkflowId Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b37dbe90-84c0-4ad9-8e22-8a5b49986ebf Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/disable_agentic_workflow.cjs | 65 +--------------- .../js/disable_agentic_workflow.test.cjs | 52 ++++++++++++- actions/setup/js/generate_footer.cjs | 78 +++++++++++++++++++ pkg/workflow/maintenance_workflow_test.go | 2 +- pkg/workflow/repo_config.go | 2 +- 5 files changed, 134 insertions(+), 65 deletions(-) diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs index 9d57f28f5c4..08e4ae066ec 100644 --- a/actions/setup/js/disable_agentic_workflow.cjs +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -4,70 +4,11 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_NOT_FOUND } = require("./error_codes.cjs"); const { ensureLabelExists, validateLabeledIssueEvent, removeLabelSafely } = require("./label_trigger_helpers.cjs"); +const { extractWorkflowId, isValidWorkflowId } = require("./generate_footer.cjs"); const DISABLE_LABEL = "agentic-workflows:disable"; const DISABLE_LABEL_COLOR = "8250df"; // GitHub purple -const DISABLE_LABEL_DESCRIPTION = "Disable the agentic workflow that created this issue or pull request"; - -/** - * Validate that an extracted workflow ID has a safe, expected format. - * Workflow IDs are file basenames (without .md) and must not contain - * path traversal sequences or other shell-unsafe characters. - * - * @param {string} id - Candidate workflow ID - * @returns {boolean} True if the ID is safe to use as a CLI argument - */ -function isValidWorkflowId(id) { - // Allow alphanumeric characters, hyphens, underscores, and dots. - // Reject anything else, as well as path traversal sequences like "..". - return id.length > 0 && id.length <= 100 && /^[\w.-]+$/.test(id) && !id.includes(".."); -} - -/** - * Extract the workflow_id from an issue or pull request body using XML comment markers. - * - * Looks for (in priority order): - * 1. Standalone marker: - * 2. Combined marker: - * 3. Workflow-call-id marker: - * (extracts the last path segment to get the workflow ID) - * - * The combined and call-id markers are only searched within actual HTML comment blocks - * to prevent unintended matches in user-provided content. - * - * @param {string|null|undefined} body - Issue or PR body - * @returns {string|null} Workflow ID or null if not found or invalid - */ -function extractWorkflowId(body) { - if (!body) return null; - - // Try standalone marker: - const standaloneMatch = body.match(//); - if (standaloneMatch) { - const id = standaloneMatch[1].trim(); - return isValidWorkflowId(id) ? id : null; - } - - // Try combined marker, but only within HTML comment blocks that contain - // gh-aw-agentic-workflow: to avoid matching user content. - const commentMatch = body.match(/ - // The call-id has the form "owner/repo/workflow-id"; extract the last non-empty path segment. - const callIdMatch = body.match(//); - if (callIdMatch) { - const segments = callIdMatch[1].trim().split("/"); - const id = segments[segments.length - 1].trim(); - if (id.length === 0) return null; - return isValidWorkflowId(id) ? id : null; - } - - return null; -} +const DISABLE_LABEL_DESCRIPTION = "Disable the agentic workflow that created this issue"; /** * Disable an agentic workflow when the "agentic-workflows:disable" label is applied to an issue. @@ -150,4 +91,4 @@ async function main() { await removeLabelSafely(owner, repo, issueNumber, DISABLE_LABEL); } -module.exports = { main, extractWorkflowId, isValidWorkflowId }; +module.exports = { main }; diff --git a/actions/setup/js/disable_agentic_workflow.test.cjs b/actions/setup/js/disable_agentic_workflow.test.cjs index a9468185ba8..d92446bd364 100644 --- a/actions/setup/js/disable_agentic_workflow.test.cjs +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -4,7 +4,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { extractWorkflowId, isValidWorkflowId, main } = req("./disable_agentic_workflow.cjs"); +const { main } = req("./disable_agentic_workflow.cjs"); +const { extractWorkflowId, isValidWorkflowId } = req("./generate_footer.cjs"); // ─── global mocks ──────────────────────────────────────────────────────────── @@ -310,4 +311,53 @@ describe("extractWorkflowId", () => { const body = ""; expect(extractWorkflowId(body)).toBeNull(); }); + + // ─── extension normalization ─────────────────────────────────────────────── + + it("strips .yml extension from standalone marker", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("my-workflow"); + }); + + it("strips .yaml extension from standalone marker", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("my-workflow"); + }); + + it("strips .lock.yml extension from standalone marker", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("my-workflow"); + }); + + it("strips .yml extension from combined marker workflow_id field", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("ci-doctor"); + }); + + it("strips .yml extension from call-id last segment", () => { + const body = ""; + expect(extractWorkflowId(body)).toBe("my-workflow"); + }); +}); + +describe("isValidWorkflowId", () => { + it("returns true for a plain workflow ID", () => { + expect(isValidWorkflowId("my-workflow")).toBe(true); + }); + + it("returns true for a workflow ID with dots and underscores", () => { + expect(isValidWorkflowId("code_review.v2")).toBe(true); + }); + + it("returns false for empty string", () => { + expect(isValidWorkflowId("")).toBe(false); + }); + + it("returns false for path traversal", () => { + expect(isValidWorkflowId("../secrets")).toBe(false); + }); + + it("returns false for ID with shell-special characters", () => { + expect(isValidWorkflowId("my;workflow")).toBe(false); + }); }); diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index b920e29ee83..70dfaeb1a0f 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -215,12 +215,90 @@ function getCloseKeyMarkerContent(closeKey) { return `gh-aw-close-key: ${closeKey}`; } +/** + * Validate that an extracted workflow ID has a safe, expected format. + * Workflow IDs are file basenames (without .md) and must not contain + * path traversal sequences or other shell-unsafe characters. + * + * @param {string} id - Candidate workflow ID + * @returns {boolean} True if the ID is safe to use + */ +function isValidWorkflowId(id) { + // Allow alphanumeric characters, hyphens, underscores, and dots. + // Reject anything else, as well as path traversal sequences like "..". + return id.length > 0 && id.length <= 100 && /^[\w.-]+$/.test(id) && !id.includes(".."); +} + +/** + * Extract the workflow_id from an issue body using XML comment markers. + * + * Looks for (in priority order): + * 1. Standalone marker: + * 2. Combined marker: + * 3. Workflow-call-id marker: + * (extracts the last path segment to get the workflow ID) + * + * The combined and call-id markers are only searched within actual HTML comment blocks + * to prevent unintended matches in user-provided content. + * + * Any `.yml`, `.yaml`, or `.lock.yml` extension in the extracted ID is stripped so the + * result is always a bare workflow identifier (filename without extension). + * + * @param {string|null|undefined} body - Issue body + * @returns {string|null} Workflow ID or null if not found or invalid + */ +function extractWorkflowId(body) { + if (!body) return null; + + // Try standalone marker: + const standaloneMatch = body.match(//); + if (standaloneMatch) { + const id = normalizeWorkflowId(standaloneMatch[1].trim()); + return isValidWorkflowId(id) ? id : null; + } + + // Try combined marker, but only within HTML comment blocks that contain + // gh-aw-agentic-workflow: to avoid matching user content. + const commentMatch = body.match(/ + // The call-id has the form "owner/repo/workflow-id"; extract the last non-empty path segment. + const callIdMatch = body.match(//); + if (callIdMatch) { + const segments = callIdMatch[1].trim().split("/"); + const raw = segments[segments.length - 1].trim(); + if (raw.length === 0) return null; + const id = normalizeWorkflowId(raw); + return isValidWorkflowId(id) ? id : null; + } + + return null; +} + +/** + * Strip workflow file extension (.yml, .yaml, .lock.yml) from a workflow identifier. + * This ensures we always work with the bare workflow ID (basename without extension). + * + * @param {string} id - Raw workflow identifier + * @returns {string} Bare workflow identifier + */ +function normalizeWorkflowId(id) { + return id.replace(/\.(lock\.yml|yml|yaml)$/i, ""); +} + module.exports = { generateXMLMarker, generateWorkflowIdMarker, generateWorkflowCallIdMarker, getWorkflowIdMarkerContent, matchesWorkflowId, + isValidWorkflowId, + extractWorkflowId, + normalizeWorkflowId, generateExpiredEntityFooter, getExpiredEntityCautionAlert, normalizeCloseOlderKey, diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index fb77fd27705..70702e24d0b 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -619,7 +619,7 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { t.Errorf("label_disable_agentic_workflow job should use disable_agentic_workflow.cjs script in:\n%s", disableJobSection) } - // Verify the job includes the CLI installation and permission check steps + // Verify the job includes the permission check step if !strings.Contains(disableJobSection, "check_team_member.cjs") { t.Errorf("label_disable_agentic_workflow job should check permissions using check_team_member.cjs in:\n%s", disableJobSection) } diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 76ea65da929..49a67aeeb45 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -9,7 +9,7 @@ // { // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all -// "action_failure_issue_expires": 72 // expiration (hours) for conclusion failure issues +// "action_failure_issue_expires": 72, // expiration (hours) for conclusion failure issues // "label_triggers": false // set to false to disable all label-triggered jobs // } // maintenance jobs (default: ubuntu-slim) // } From 4929821c06e5bbc265a49aabdd32a1def63e3414 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:09:48 +0000 Subject: [PATCH 14/15] fix: add explicit permission gate on label-triggered operation steps Add `id: check_permissions` to the Check admin/maintainer permissions step and `if: steps.check_permissions.outcome == 'success'` to the operation steps (Disable agentic workflow / Apply safe outputs) in both label_disable_agentic_workflow and label_apply_safe_outputs jobs. This makes the permission boundary explicit and ensures the operation step cannot run if the member check step fails for any reason (e.g. if someone accidentally adds continue-on-error: true to the check step in the future). Regenerated agentics-maintenance.yml accordingly. Updated tests to assert the id and if condition are present. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/417e6a72-34c6-4b6c-9668-7835ebc8255a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 4 ++++ pkg/workflow/maintenance_workflow_test.go | 22 +++++++++++++++++++++- pkg/workflow/maintenance_workflow_yaml.go | 4 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 5378c14479f..4990a9cdd05 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -577,6 +577,7 @@ jobs: destination: ${{ runner.temp }}/gh-aw/actions - name: Check admin/maintainer permissions + id: check_permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -587,6 +588,7 @@ jobs: await main(); - name: Disable agentic workflow + if: ${{ steps.check_permissions.outcome == 'success' }} uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -619,6 +621,7 @@ jobs: destination: ${{ runner.temp }}/gh-aw/actions - name: Check admin/maintainer permissions + id: check_permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -629,6 +632,7 @@ jobs: await main(); - name: Apply safe outputs from referenced run + if: ${{ steps.check_permissions.outcome == 'success' }} uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 70702e24d0b..246f7d66377 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -619,10 +619,17 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { t.Errorf("label_disable_agentic_workflow job should use disable_agentic_workflow.cjs script in:\n%s", disableJobSection) } - // Verify the job includes the permission check step + // Verify the job includes the permission check step with an id and that the operation step + // has an explicit if condition referencing that id (so unauthorized users cannot bypass the check) if !strings.Contains(disableJobSection, "check_team_member.cjs") { t.Errorf("label_disable_agentic_workflow job should check permissions using check_team_member.cjs in:\n%s", disableJobSection) } + if !strings.Contains(disableJobSection, "id: check_permissions") { + t.Errorf("label_disable_agentic_workflow permission check step should have id: check_permissions in:\n%s", disableJobSection) + } + if !strings.Contains(disableJobSection, "steps.check_permissions.outcome == 'success'") { + t.Errorf("label_disable_agentic_workflow operation step should have if: steps.check_permissions.outcome == 'success' in:\n%s", disableJobSection) + } } func TestBuildLabeledDisableCondition(t *testing.T) { @@ -763,6 +770,19 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { if !strings.Contains(yaml, "label_apply_safe_outputs:") { t.Error("By default (no config) the label_apply_safe_outputs job should be present") } + + // Verify label_apply_safe_outputs job has an explicit step id and if condition so that + // the operation step only runs when the permission check passes + applySafeIdx := strings.Index(yaml, "\n label_apply_safe_outputs:") + if applySafeIdx != -1 { + applySection := yaml[applySafeIdx:min(applySafeIdx+2000, len(yaml))] + if !strings.Contains(applySection, "id: check_permissions") { + t.Errorf("label_apply_safe_outputs permission check step should have id: check_permissions in:\n%s", applySection[:min(500, len(applySection))]) + } + if !strings.Contains(applySection, "steps.check_permissions.outcome == 'success'") { + t.Errorf("label_apply_safe_outputs operation step should have if: steps.check_permissions.outcome == 'success' in:\n%s", applySection[:min(500, len(applySection))]) + } + } } func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index a80085428a2..53f31cb68ab 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -651,6 +651,7 @@ jobs: destination: ${{ runner.temp }}/gh-aw/actions - name: Check admin/maintainer permissions + id: check_permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -661,6 +662,7 @@ jobs: await main(); - name: Disable agentic workflow + if: ${{ steps.check_permissions.outcome == 'success' }} uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -698,6 +700,7 @@ jobs: destination: ${{ runner.temp }}/gh-aw/actions - name: Check admin/maintainer permissions + id: check_permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -708,6 +711,7 @@ jobs: await main(); - name: Apply safe outputs from referenced run + if: ${{ steps.check_permissions.outcome == 'success' }} uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e3b1c8c802070392c887349670432508d8c3d8db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:01:59 +0000 Subject: [PATCH 15/15] fix: make label_triggers opt-in (default false), enable in this repo's aw.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IsLabelTriggerEnabled() now returns false when nil/omitted (opt-in semantics) - buildMaintenanceWorkflowYAML default for disableLabelTrigger changed to true (label jobs absent unless repoConfig explicitly sets label_triggers: true) - .github/workflows/aw.json: adds "label_triggers": true to enable the feature in the gh-aw repo itself - Header comment and API docs updated to reflect opt-in semantics - Tests updated: DefaultTrue → DefaultFalse, added ExplicitTrue test, DisableAgenticWorkflowJob now passes explicit label_triggers: true config - Regenerated agentics-maintenance.yml (label jobs still present via aw.json opt-in) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7faa996f-d335-4234-952e-d874c27616ee Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/aw.json | 3 +- pkg/workflow/maintenance_workflow.go | 2 +- pkg/workflow/maintenance_workflow_test.go | 61 ++++++++++++++++++++--- pkg/workflow/repo_config.go | 14 +++--- pkg/workflow/repo_config_test.go | 4 +- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json index 4addc059ec6..91e7c509c9d 100644 --- a/.github/workflows/aw.json +++ b/.github/workflows/aw.json @@ -1,5 +1,6 @@ { "maintenance": { - "action_failure_issue_expires": 12 + "action_failure_issue_expires": 12, + "label_triggers": true } } diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index e136a4dc608..2cccb371942 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -125,7 +125,7 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s // Determine the runs-on value to use for all maintenance jobs. const defaultRunsOn = "ubuntu-slim" var configuredRunsOn RunsOnValue - disableLabelTrigger := false // default: include the label-triggered job + disableLabelTrigger := true // default: disable label-triggered jobs (opt-in) if repoConfig != nil && repoConfig.Maintenance != nil { configuredRunsOn = repoConfig.Maintenance.RunsOn disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 246f7d66377..dffb3730334 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -551,7 +551,11 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { } tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + trueVal := true + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{LabelTriggers: &trueVal}, + } + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -740,7 +744,7 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { } tmpDir := t.TempDir() - // Default: LabelTriggers is nil (omitted) → treated as true → jobs included + // Default: LabelTriggers is nil (omitted) → treated as false (opt-in semantics) → jobs absent err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) @@ -751,9 +755,50 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { } yaml := string(content) - // Issues labeled trigger should be present by default + // Issues labeled trigger should NOT be present by default (opt-in required) + if strings.Contains(yaml, " issues:\n types: [labeled]") { + t.Error("By default (no config) the issues labeled trigger should NOT be present — label_triggers must be explicitly enabled") + } + + // The label_disable_agentic_workflow job should NOT be present by default + if strings.Contains(yaml, "label_disable_agentic_workflow:") { + t.Error("By default (no config) the label_disable_agentic_workflow job should NOT be present — label_triggers must be explicitly enabled") + } + + // The label_apply_safe_outputs job should NOT be present by default + if strings.Contains(yaml, "label_apply_safe_outputs:") { + t.Error("By default (no config) the label_apply_safe_outputs job should NOT be present — label_triggers must be explicitly enabled") + } +} + +func TestGenerateMaintenanceWorkflow_LabelTriggers_ExplicitTrue(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + trueVal := true + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{LabelTriggers: &trueVal}, + } + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + // Issues labeled trigger should be present when explicitly enabled if !strings.Contains(yaml, " issues:\n types: [labeled]") { - t.Error("By default (no config) the issues labeled trigger should be present") + t.Error("When label_triggers: true the issues labeled trigger should be present") } // pull_request labeled trigger should never be present (issues-only by design) @@ -761,14 +806,14 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { t.Error("pull_request labeled trigger should never be present (issues-only)") } - // The label_disable_agentic_workflow job should be present by default + // The label_disable_agentic_workflow job should be present when explicitly enabled if !strings.Contains(yaml, "label_disable_agentic_workflow:") { - t.Error("By default (no config) the label_disable_agentic_workflow job should be present") + t.Error("When label_triggers: true the label_disable_agentic_workflow job should be present") } - // The label_apply_safe_outputs job should be present by default + // The label_apply_safe_outputs job should be present when explicitly enabled if !strings.Contains(yaml, "label_apply_safe_outputs:") { - t.Error("By default (no config) the label_apply_safe_outputs job should be present") + t.Error("When label_triggers: true the label_apply_safe_outputs job should be present") } // Verify label_apply_safe_outputs job has an explicit step id and if condition so that diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 49a67aeeb45..0f151053c8e 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -10,7 +10,7 @@ // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72, // expiration (hours) for conclusion failure issues -// "label_triggers": false // set to false to disable all label-triggered jobs +// "label_triggers": true // set to true to enable all label-triggered jobs (opt-in) // } // maintenance jobs (default: ubuntu-slim) // } // @@ -76,17 +76,17 @@ type MaintenanceConfig struct { // LabelTriggers controls all label-triggered jobs (disable_agentic_workflow, // label_apply_safe_outputs, etc.). - // The value is treated as a feature-active flag: true (or omitted/nil) means - // all label-triggered jobs ARE included; false explicitly opts out. - // To opt out, set label_triggers: false in aw.json. + // The value is treated as an opt-in flag: only true enables the jobs. + // nil (omitted) or false both disable label-triggered jobs. + // To opt in, set label_triggers: true in aw.json. LabelTriggers *bool `json:"label_triggers,omitempty"` } -// IsLabelTriggerEnabled returns true unless label_triggers is explicitly set to false. -// The default (nil / omitted) is treated as enabled (true). +// IsLabelTriggerEnabled returns true only when label_triggers is explicitly set to true. +// The default (nil / omitted) is treated as disabled (false) — opt-in semantics. func (m *MaintenanceConfig) IsLabelTriggerEnabled() bool { if m == nil || m.LabelTriggers == nil { - return true + return false } return *m.LabelTriggers } diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 32cc3cd6a09..d19097e91a4 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -112,7 +112,7 @@ func TestLoadRepoConfig_LabelTriggersDisable(t *testing.T) { assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "setting label_triggers: false explicitly opts out — label-triggered jobs should not be included") } -func TestLoadRepoConfig_LabelTriggers_DefaultTrue(t *testing.T) { +func TestLoadRepoConfig_LabelTriggers_DefaultFalse(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"maintenance": {}}`) @@ -120,7 +120,7 @@ func TestLoadRepoConfig_LabelTriggers_DefaultTrue(t *testing.T) { require.NoError(t, err, "valid aw.json should load without error") require.NotNil(t, cfg.Maintenance, "maintenance config should be set") assert.Nil(t, cfg.Maintenance.LabelTriggers, "label_triggers should be nil when not specified") - assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label triggers should be enabled by default (nil = true)") + assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label triggers should be disabled by default (nil = false)") } func TestLoadRepoConfig_LabelTriggers_ExplicitTrue(t *testing.T) {