diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index b243a781cfe..4990a9cdd05 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -40,6 +40,8 @@ on: - main paths: - '.github/workflows/*.md' + issues: + types: [labeled] workflow_dispatch: inputs: operation: @@ -554,6 +556,94 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); await main(); + 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: + actions: write + contents: read + issues: 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 + id: check_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: Disable agentic workflow + if: ${{ steps.check_permissions.outcome == 'success' }} + 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/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 + id: check_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 + if: ${{ steps.check_permissions.outcome == 'success' }} + 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/.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/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index c2de0842f20..935617893e3 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -121,12 +121,13 @@ async function main() { core.info(`ℹ️ Label already exists: ${labelName}`); skipped++; } else { + const color = deterministicLabelColor(labelName); try { await github.rest.issues.createLabel({ owner, repo, name: labelName, - color: deterministicLabelColor(labelName), + color, description: "", }); core.info(`✅ Created label: ${labelName}`); diff --git a/actions/setup/js/create_labels.test.cjs b/actions/setup/js/create_labels.test.cjs index 3abd040d3bd..5e08c897634 100644 --- a/actions/setup/js/create_labels.test.cjs +++ b/actions/setup/js/create_labels.test.cjs @@ -91,7 +91,7 @@ describe("main", () => { stderr: "", }); - // Default: repo has one existing label + // Default: repo has "bug" mockGithub.paginate.mockResolvedValue([{ name: "bug" }]); mockGithub.rest.issues.createLabel.mockResolvedValue({}); }); @@ -99,7 +99,6 @@ describe("main", () => { it("creates labels that are missing from the repository", async () => { await main(); - expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledTimes(2); const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); expect(names).toContain("enhancement"); expect(names).toContain("docs"); @@ -137,12 +136,13 @@ describe("main", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already existed")); }); - it("does nothing when no labels are found", async () => { + it("does nothing when no workflow labels are found", async () => { mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: JSON.stringify([{ labels: [] }, {}]), stderr: "", }); + mockGithub.paginate.mockResolvedValue([]); await main(); diff --git a/actions/setup/js/disable_agentic_workflow.cjs b/actions/setup/js/disable_agentic_workflow.cjs new file mode 100644 index 00000000000..08e4ae066ec --- /dev/null +++ b/actions/setup/js/disable_agentic_workflow.cjs @@ -0,0 +1,94 @@ +// @ts-check +/// + +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"; + +/** + * 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 via the GitHub REST API, and posts a comment + * confirming the action. + * + * @returns {Promise} + */ +async function main() { + const ctx = validateLabeledIssueEvent(DISABLE_LABEL); + if (!ctx) return; + + const { owner, repo, issueNumber, body } = ctx; + + // Ensure the disable label exists so it is available for future use + await ensureLabelExists(owner, repo, DISABLE_LABEL, DISABLE_LABEL_COLOR, DISABLE_LABEL_DESCRIPTION); + + core.info(`Processing issue #${issueNumber} labeled with '${DISABLE_LABEL}'`); + + // Extract workflow ID from body XML comment markers + const workflowId = extractWorkflowId(body); + + if (!workflowId) { + core.warning(`Could not find workflow ID in issue #${issueNumber} body. Expected a marker.`); + await github.rest.issues.createComment({ + owner, + repo, + 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, trigger the maintenance workflow with the \`disable\` operation.`, + }); + core.setFailed(`${ERR_NOT_FOUND}: No workflow ID marker found in issue #${issueNumber}`); + return; + } + + core.info(`Found workflow ID: ${workflowId}`); + core.info(`Disabling agentic workflow '${workflowId}'...`); + + // Disable the workflow via the GitHub REST API using its compiled lock file name + const lockFileName = `${workflowId}.lock.yml`; + try { + await github.rest.actions.disableWorkflow({ owner, repo, workflow_id: lockFileName }); + } catch (err) { + const msg = getErrorMessage(err); + core.error(`Failed to disable workflow '${workflowId}': ${msg}`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: + `> [!WARNING]\n` + + `> **Failed to disable agentic workflow \`${workflowId}\`**\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; + } + + core.info(`Successfully disabled workflow '${workflowId}'`); + + // Post a success comment on the issue + await github.rest.issues.createComment({ + owner, + repo, + 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 #${issueNumber}`); + + // Remove the disable label now that the action is complete + await removeLabelSafely(owner, repo, issueNumber, DISABLE_LABEL); +} + +module.exports = { main }; 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..d92446bd364 --- /dev/null +++ b/actions/setup/js/disable_agentic_workflow.test.cjs @@ -0,0 +1,363 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { main } = req("./disable_agentic_workflow.cjs"); +const { extractWorkflowId, isValidWorkflowId } = req("./generate_footer.cjs"); + +// ─── global mocks ──────────────────────────────────────────────────────────── + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), +}; + +const mockGithub = { + rest: { + actions: { + disableWorkflow: vi.fn(), + }, + issues: { + createComment: vi.fn(), + removeLabel: vi.fn(), + createLabel: 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.github = mockGithub; +global.context = mockContext; + +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"; + + // 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) + const alreadyExists = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(alreadyExists); + + // 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 via REST API and posts a success comment", async () => { + await main(); + + 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", + 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("skips silently when event type is pull_request", 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.actions.disableWorkflow).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + 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 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 () => { + 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(); + }); + + 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); + + await main(); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + name: "agentic-workflows:disable", + color: "8250df", + }) + ); + }); + + 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(mockGithub.rest.actions.disableWorkflow).toHaveBeenCalled(); // disable still ran + }); +}); + +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.", + "", + "> Closed by [My Workflow](https://github.com/owner/repo/actions/runs/123)", + "", + "", + "", + "", + ].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(); + }); + + // ─── 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(); + }); + + it("returns null when call-id ends with a trailing slash (empty last segment)", () => { + 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/actions/setup/js/label_apply_safe_outputs.cjs b/actions/setup/js/label_apply_safe_outputs.cjs new file mode 100644 index 00000000000..5fbe57db83e --- /dev/null +++ b/actions/setup/js/label_apply_safe_outputs.cjs @@ -0,0 +1,131 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_NOT_FOUND } = require("./error_codes.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 +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; +} + +/** + * 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 ctx = validateLabeledIssueEvent(APPLY_SAFE_OUTPUTS_LABEL); + if (!ctx) return; + + const { owner, repo, issueNumber, body } = ctx; + + // Ensure the label exists so it is available for future use + await ensureLabelExists(owner, repo, APPLY_SAFE_OUTPUTS_LABEL, APPLY_SAFE_OUTPUTS_LABEL_COLOR, APPLY_SAFE_OUTPUTS_LABEL_DESCRIPTION); + + core.info(`Processing issue #${issueNumber} labeled with '${APPLY_SAFE_OUTPUTS_LABEL}'`); + + // Extract run URL from body XML comment markers + 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 + await removeLabelSafely(owner, repo, issueNumber, APPLY_SAFE_OUTPUTS_LABEL); +} + +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 new file mode 100644 index 00000000000..087f296985d --- /dev/null +++ b/actions/setup/js/label_apply_safe_outputs.test.cjs @@ -0,0 +1,226 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { extractRunUrl, main } = req("./label_apply_safe_outputs.cjs"); + +// ─── global mocks ──────────────────────────────────────────────────────────── + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: 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.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(); + }); +}); + +// ─── 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 ensureLabelExists — + // 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/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/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.* diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index b457ce56010..e2ad0f8dadf 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] + }, + "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 176a8a80902..1ec68acb39b 100644 --- a/pkg/workflow/maintenance_conditions.go +++ b/pkg/workflow/maintenance_conditions.go @@ -102,6 +102,38 @@ func buildDispatchOperationCondition(operation string) ConditionNode { ) } +// buildLabeledDisableCondition creates a condition for the disable_agentic_workflow job +// 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( + BuildEventTypeEquals("issues"), + BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral("agentic-workflows:disable"), + ), + ), + ) +} + +// 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.go b/pkg/workflow/maintenance_workflow.go index 7e64b7d9074..2cccb371942 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 + disableLabelTrigger := true // default: disable label-triggered jobs (opt-in) if repoConfig != nil && repoConfig.Maintenance != nil { configuredRunsOn = repoConfig.Maintenance.RunsOn + disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() } 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 5b88c2186b4..dffb3730334 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -538,6 +538,298 @@ 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() + 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) + + const jobSectionSearchRange = 2000 + + // 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 must NOT include pull_request: types: [labeled] trigger (issues-only)") + } + + // Verify the label_disable_agentic_workflow job exists + disableJobIdx := strings.Index(yaml, "\n label_disable_agentic_workflow:") + if disableJobIdx == -1 { + t.Fatal("Job label_disable_agentic_workflow not found in generated workflow") + } + // 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("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("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("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("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("label_disable_agentic_workflow job should have actions: write permission in:\n%s", disableJobSection) + } + if !strings.Contains(disableJobSection, "contents: read") { + t.Errorf("label_disable_agentic_workflow job should have contents: read permission in:\n%s", disableJobSection) + } + if strings.Contains(disableJobSection, "contents: write") { + 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("label_disable_agentic_workflow job should have issues: write permission in:\n%s", disableJobSection) + } + if strings.Contains(disableJobSection, "pull-requests: write") { + 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("label_disable_agentic_workflow job should use disable_agentic_workflow.cjs script in:\n%s", disableJobSection) + } + + // 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) { + condition := buildLabeledDisableCondition() + rendered := RenderCondition(condition) + + // 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 must not include pull_request event (issues-only), 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 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", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + falseVal := false + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{LabelTriggers: &falseVal}, + } + 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 trigger should be absent + if strings.Contains(yaml, " issues:\n types: [labeled]") { + 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("pull_request labeled trigger should never be present (issues-only)") + } + + // 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 + 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_LabelTriggers_Default(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + // 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) + } + 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 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("When label_triggers: true the issues 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 label_disable_agentic_workflow job should be present when explicitly enabled + if !strings.Contains(yaml, "label_disable_agentic_workflow:") { + t.Error("When label_triggers: true the label_disable_agentic_workflow job should be present") + } + + // The label_apply_safe_outputs job should be present when explicitly enabled + if !strings.Contains(yaml, "label_apply_safe_outputs:") { + 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 + // 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) { const jobSectionSearchRange = 500 @@ -578,7 +870,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))]) } @@ -894,6 +1186,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { 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. + // 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 != 5 { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 57eaf5d996c..53f31cb68ab 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,6 +58,13 @@ on: `) } + // Add label-event trigger only when the label-triggered jobs are enabled + if !disableLabelTrigger { + yaml.WriteString(` issues: + types: [labeled] +`) + } + yaml.WriteString(` workflow_dispatch: inputs: operation: @@ -614,6 +622,109 @@ jobs: await main(); `) + // 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(` + label_disable_agentic_workflow: + if: ${{ ` + RenderCondition(disableLabelCondition) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + actions: write + contents: read + issues: 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 + id: check_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: Disable agentic workflow + if: ${{ steps.check_permissions.outcome == 'success' }} + 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/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 + id: check_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 + if: ${{ steps.check_permissions.outcome == 'success' }} + 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 // 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 diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 15197dac3ad..0f151053c8e 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -9,7 +9,8 @@ // { // "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": true // set to true to enable all label-triggered jobs (opt-in) // } // maintenance jobs (default: ubuntu-slim) // } // @@ -72,6 +73,22 @@ 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"` + + // LabelTriggers controls all label-triggered jobs (disable_agentic_workflow, + // label_apply_safe_outputs, etc.). + // The value is treated as an opt-in flag: only true enables the jobs. + // 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 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 false + } + 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 6771187de7b..d19097e91a4 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -100,6 +100,42 @@ func TestLoadRepoConfig_SchemaViolation(t *testing.T) { assert.Error(t, err, "schema violation should return an error") } +func TestLoadRepoConfig_LabelTriggersDisable(t *testing.T) { + dir := t.TempDir() + 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.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_LabelTriggers_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.Nil(t, cfg.Maintenance.LabelTriggers, "label_triggers should be nil when not specified") + assert.False(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label triggers should be disabled by default (nil = false)") +} + +func TestLoadRepoConfig_LabelTriggers_ExplicitTrue(t *testing.T) { + dir := t.TempDir() + 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.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. func TestLoadRepoConfig_UnknownProperty(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"unknown_property": "value"}`)