From 5dedda9a02fc73db7cac6cf559ec4f0f6c061112 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:44:47 +0000 Subject: [PATCH 1/3] feat: add apply_safe_outputs job to agentic-maintenance workflow - Add run_url workflow_dispatch input (accepts plain run ID or full GitHub Actions run URL) - Add apply_safe_outputs job that downloads agent artifact from specified run and replays safe outputs - Create apply_safe_outputs_replay.cjs JS driver that parses run URLs, downloads artifacts via gh CLI, auto-builds handler config, and calls safe_output_handler_manager - Create apply_safe_outputs_replay.test.cjs with 11 unit tests - Update existing scheduled job conditions to also exclude run_url - Update TestGenerateMaintenanceWorkflow_OperationJobConditions test - Recompile agentics-maintenance.yml Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2bcfe7a2-a8bc-4880-bb08-85f28f76d64e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 58 +++++- .../setup/js/apply_safe_outputs_replay.cjs | 184 +++++++++++++++++ .../js/apply_safe_outputs_replay.test.cjs | 195 ++++++++++++++++++ pkg/workflow/maintenance_workflow.go | 61 +++++- pkg/workflow/maintenance_workflow_test.go | 22 +- 5 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 actions/setup/js/apply_safe_outputs_replay.cjs create mode 100644 actions/setup/js/apply_safe_outputs_replay.test.cjs diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index d308a754633..0f4a60f060c 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -48,12 +48,17 @@ on: - 'enable' - 'update' - 'upgrade' + run_url: + description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345)' + required: false + type: string + default: '' permissions: {} jobs: close-expired-entities: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim permissions: discussions: write @@ -150,8 +155,53 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/run_operation_update_upgrade.cjs'); await main(); + apply_safe_outputs: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != '' && !github.event.repository.fork }} + 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Apply Safe Outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_RUN_URL: ${{ github.event.inputs.run_url }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); + await main(); + compile-workflows: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim permissions: contents: read @@ -191,7 +241,7 @@ jobs: await main(); zizmor-scan: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim needs: compile-workflows permissions: @@ -215,7 +265,7 @@ jobs: echo "✓ Zizmor security scan completed" secret-validation: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim permissions: contents: read diff --git a/actions/setup/js/apply_safe_outputs_replay.cjs b/actions/setup/js/apply_safe_outputs_replay.cjs new file mode 100644 index 00000000000..ea109f26aa3 --- /dev/null +++ b/actions/setup/js/apply_safe_outputs_replay.cjs @@ -0,0 +1,184 @@ +// @ts-check +/// + +/** + * Apply Safe Outputs Replay Driver + * + * Downloads the agent output artifact from a previous workflow run and replays + * the safe outputs, applying them to the repository. + * + * Called from the `apply_safe_outputs` job in the agentic-maintenance workflow. + * + * Required environment variables: + * GH_AW_RUN_URL - Run URL or run ID to replay safe outputs from. + * Accepts a full URL (https://github.com/{owner}/{repo}/actions/runs/{runId}) + * or a plain run ID (digits only). + * GH_TOKEN - GitHub token for artifact download via `gh run download`. + * + * Optional environment variables: + * GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG - If set, overrides the auto-generated handler config. + */ + +const fs = require("fs"); +const path = require("path"); + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_CONFIG, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs"); +const { AGENT_OUTPUT_FILENAME, TMP_GH_AW_PATH } = require("./constants.cjs"); + +/** + * Parse a run ID from a run URL or plain run ID string. + * + * Accepts: + * - A plain run ID: "23560193313" + * - A full run URL: "https://github.com/{owner}/{repo}/actions/runs/{runId}" + * - A run URL with job: "https://github.com/{owner}/{repo}/actions/runs/{runId}/job/{jobId}" + * + * @param {string} runUrl - The run URL or run ID to parse + * @returns {{ runId: string, owner: string|null, repo: string|null }} Parsed components + */ +function parseRunUrl(runUrl) { + if (!runUrl || typeof runUrl !== "string") { + throw new Error(`${ERR_VALIDATION}: run_url is required`); + } + + const trimmed = runUrl.trim(); + + // Check if it's a plain run ID (digits only) + if (/^\d+$/.test(trimmed)) { + return { runId: trimmed, owner: null, repo: null }; + } + + // Parse a full GitHub Actions URL + // Pattern: https://github.com/{owner}/{repo}/actions/runs/{runId}[/job/{jobId}] + const match = trimmed.match(/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)/); + if (match) { + return { runId: match[3], owner: match[1], repo: match[2] }; + } + + throw new Error(`${ERR_VALIDATION}: Cannot parse run ID from: ${trimmed}. ` + `Expected a plain run ID (digits only) or a GitHub Actions run URL ` + `(https://github.com/{owner}/{repo}/actions/runs/{runId}).`); +} + +/** + * Download the agent artifact from a workflow run using `gh run download`. + * + * @param {string} runId - The workflow run ID + * @param {string} destDir - Destination directory for the downloaded artifact + * @param {string|null} repoSlug - Optional repository slug (owner/repo) + * @returns {Promise} Path to the downloaded agent_output.json file + */ +async function downloadAgentArtifact(runId, destDir, repoSlug) { + core.info(`Downloading agent artifact from run ${runId}...`); + + fs.mkdirSync(destDir, { recursive: true }); + + const args = ["run", "download", runId, "--name", "agent", "--dir", destDir]; + if (repoSlug) { + args.push("--repo", repoSlug); + } + + const exitCode = await exec.exec("gh", args); + if (exitCode !== 0) { + throw new Error(`${ERR_SYSTEM}: Failed to download agent artifact from run ${runId}`); + } + + const outputFile = path.join(destDir, AGENT_OUTPUT_FILENAME); + if (!fs.existsSync(outputFile)) { + throw new Error(`${ERR_SYSTEM}: Agent output file not found at ${outputFile} after download. ` + `Ensure run ${runId} has an "agent" artifact containing ${AGENT_OUTPUT_FILENAME}.`); + } + + core.info(`✓ Agent artifact downloaded to ${outputFile}`); + return outputFile; +} + +/** + * Build a handler config from the items present in the agent output file. + * Each item type found in the output is enabled (with an empty config object). + * + * @param {string} agentOutputFile - Path to the agent_output.json file + * @returns {Object} Handler config keyed by normalized type name + */ +function buildHandlerConfigFromOutput(agentOutputFile) { + const content = fs.readFileSync(agentOutputFile, "utf8"); + const validatedOutput = JSON.parse(content); + + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No items found in agent output; handler config will be empty"); + return {}; + } + + const config = {}; + for (const item of validatedOutput.items) { + if (item.type && typeof item.type === "string") { + // Normalize type: convert dashes to underscores (mirrors safe_outputs_append.cjs) + const normalizedType = item.type.replace(/-/g, "_"); + config[normalizedType] = {}; + } + } + + core.info(`Handler config built from ${validatedOutput.items.length} item(s): ${Object.keys(config).join(", ")}`); + return config; +} + +/** + * Download the agent artifact from a previous run and apply the safe outputs. + * + * @returns {Promise} + */ +async function main() { + const runUrl = process.env.GH_AW_RUN_URL; + if (!runUrl) { + core.setFailed(`${ERR_CONFIG}: GH_AW_RUN_URL environment variable is required but not set`); + return; + } + + core.info(`Applying safe outputs from run: ${runUrl}`); + + // Parse run ID and optional owner/repo from the URL + let runId, owner, repo; + try { + ({ runId, owner, repo } = parseRunUrl(runUrl)); + } catch (error) { + core.setFailed(getErrorMessage(error)); + return; + } + + core.info(`Parsed run ID: ${runId}`); + + // Determine repo slug: prefer URL-parsed value, fall back to current repo context + const repoSlug = owner && repo ? `${owner}/${repo}` : `${context.repo.owner}/${context.repo.repo}`; + core.info(`Target repository: ${repoSlug}`); + + // Download the agent artifact into /tmp/gh-aw/ + const destDir = TMP_GH_AW_PATH; + let agentOutputFile; + try { + agentOutputFile = await downloadAgentArtifact(runId, destDir, owner && repo ? repoSlug : null); + } catch (error) { + core.setFailed(getErrorMessage(error)); + return; + } + + // Set GH_AW_AGENT_OUTPUT so the handler manager can find the output file + process.env.GH_AW_AGENT_OUTPUT = agentOutputFile; + core.info(`Set GH_AW_AGENT_OUTPUT=${agentOutputFile}`); + + // Auto-build GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG from the output if not already set + if (!process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) { + try { + const handlerConfig = buildHandlerConfigFromOutput(agentOutputFile); + process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify(handlerConfig); + core.info("Auto-configured GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG from agent output"); + } catch (error) { + core.setFailed(`Failed to build handler config: ${getErrorMessage(error)}`); + return; + } + } + + // Apply safe outputs via the handler manager + core.info("Applying safe outputs..."); + const { main: runHandlerManager } = require("./safe_output_handler_manager.cjs"); + await runHandlerManager(); +} + +module.exports = { main, parseRunUrl, buildHandlerConfigFromOutput }; diff --git a/actions/setup/js/apply_safe_outputs_replay.test.cjs b/actions/setup/js/apply_safe_outputs_replay.test.cjs new file mode 100644 index 00000000000..6202f8eba6e --- /dev/null +++ b/actions/setup/js/apply_safe_outputs_replay.test.cjs @@ -0,0 +1,195 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +/** Environment variables managed by tests */ +const TEST_ENV_VARS = ["GH_AW_RUN_URL", "GH_TOKEN", "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG", "GH_AW_AGENT_OUTPUT"]; + +describe("apply_safe_outputs_replay", () => { + let originalEnv; + let originalGlobals; + + beforeEach(() => { + originalEnv = { ...process.env }; + + originalGlobals = { + core: global.core, + github: global.github, + context: global.context, + exec: global.exec, + }; + + global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + }; + + global.github = {}; + + global.context = { + repo: { + owner: "testowner", + repo: "testrepo", + }, + }; + + global.exec = { + exec: vi.fn().mockResolvedValue(0), + getExecOutput: vi.fn(), + }; + + // Clear managed env vars + for (const key of TEST_ENV_VARS) { + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of TEST_ENV_VARS) { + if (originalEnv[key] !== undefined) { + process.env[key] = originalEnv[key]; + } else { + delete process.env[key]; + } + } + + global.core = originalGlobals.core; + global.github = originalGlobals.github; + global.context = originalGlobals.context; + global.exec = originalGlobals.exec; + + vi.clearAllMocks(); + }); + + describe("parseRunUrl", () => { + it("parses a plain run ID", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + const result = parseRunUrl("23560193313"); + expect(result.runId, "should parse run ID").toBe("23560193313"); + expect(result.owner, "owner should be null for plain ID").toBeNull(); + expect(result.repo, "repo should be null for plain ID").toBeNull(); + }); + + it("parses a full GitHub Actions run URL", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + const result = parseRunUrl("https://github.com/github/gh-aw/actions/runs/23560193313"); + expect(result.runId, "should parse run ID from URL").toBe("23560193313"); + expect(result.owner, "should parse owner").toBe("github"); + expect(result.repo, "should parse repo").toBe("gh-aw"); + }); + + it("parses a run URL that includes a job ID", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + const result = parseRunUrl("https://github.com/github/gh-aw/actions/runs/23560193313/job/68600993738"); + expect(result.runId, "should parse run ID ignoring job ID").toBe("23560193313"); + expect(result.owner, "should parse owner").toBe("github"); + expect(result.repo, "should parse repo").toBe("gh-aw"); + }); + + it("trims whitespace from the input", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + const result = parseRunUrl(" 23560193313 "); + expect(result.runId, "should trim and parse run ID").toBe("23560193313"); + }); + + it("throws for an empty string", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + expect(() => parseRunUrl(""), "should throw for empty string").toThrow(/run_url is required/); + }); + + it("throws for an invalid URL format", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + expect(() => parseRunUrl("not-a-valid-url"), "should throw for invalid format").toThrow(/Cannot parse run ID/); + }); + + it("throws for a URL without a run ID", async () => { + const { parseRunUrl } = await import("./apply_safe_outputs_replay.cjs"); + expect(() => parseRunUrl("https://github.com/owner/repo/actions"), "should throw for URL without run ID").toThrow(/Cannot parse run ID/); + }); + }); + + describe("buildHandlerConfigFromOutput", () => { + it("builds config from agent output items", async () => { + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const { buildHandlerConfigFromOutput } = await import("./apply_safe_outputs_replay.cjs"); + + const tmpFile = path.join(os.tmpdir(), `test-agent-output-${Date.now()}.json`); + const agentOutput = { + items: [ + { type: "create_issue", title: "Test issue" }, + { type: "add_comment", body: "Hello" }, + { type: "create_issue", title: "Duplicate type" }, + ], + }; + fs.writeFileSync(tmpFile, JSON.stringify(agentOutput)); + + try { + const config = buildHandlerConfigFromOutput(tmpFile); + expect(Object.keys(config), "should include create_issue").toContain("create_issue"); + expect(Object.keys(config), "should include add_comment").toContain("add_comment"); + expect(Object.keys(config).length, "should deduplicate types").toBe(2); + expect(config.create_issue, "config value should be empty object").toEqual({}); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + it("normalizes dashes to underscores in type names", async () => { + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const { buildHandlerConfigFromOutput } = await import("./apply_safe_outputs_replay.cjs"); + + const tmpFile = path.join(os.tmpdir(), `test-agent-output-${Date.now()}.json`); + const agentOutput = { + items: [{ type: "push-to-pull-request-branch", branch: "main" }], + }; + fs.writeFileSync(tmpFile, JSON.stringify(agentOutput)); + + try { + const config = buildHandlerConfigFromOutput(tmpFile); + expect(Object.keys(config), "should normalize dashes to underscores").toContain("push_to_pull_request_branch"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + it("returns empty config for output with no items", async () => { + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const { buildHandlerConfigFromOutput } = await import("./apply_safe_outputs_replay.cjs"); + + const tmpFile = path.join(os.tmpdir(), `test-agent-output-${Date.now()}.json`); + fs.writeFileSync(tmpFile, JSON.stringify({ items: [] })); + + try { + const config = buildHandlerConfigFromOutput(tmpFile); + expect(Object.keys(config).length, "config should be empty").toBe(0); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + it("returns empty config when items array is missing", async () => { + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const { buildHandlerConfigFromOutput } = await import("./apply_safe_outputs_replay.cjs"); + + const tmpFile = path.join(os.tmpdir(), `test-agent-output-${Date.now()}.json`); + fs.writeFileSync(tmpFile, JSON.stringify({})); + + try { + const config = buildHandlerConfigFromOutput(tmpFile); + expect(Object.keys(config).length, "config should be empty for missing items").toBe(0); + } finally { + fs.unlinkSync(tmpFile); + } + }); + }); +}); diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 8591e758424..9d82ea97eb3 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -199,12 +199,17 @@ on: - 'enable' - 'update' - 'upgrade' + run_url: + description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345)' + required: false + type: string + default: '' permissions: {} jobs: close-expired-entities: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim permissions: discussions: write @@ -322,6 +327,54 @@ jobs: await main(); `) + // Add apply_safe_outputs job for workflow_dispatch with run_url input + yaml.WriteString(` + apply_safe_outputs: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != '' && !github.event.repository.fork }} + runs-on: ubuntu-slim + 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: ` + GetActionPin("actions/github-script") + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Apply Safe Outputs + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_RUN_URL: ${{ github.event.inputs.run_url }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.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 @@ -329,7 +382,7 @@ jobs: // Add compile-workflows job yaml.WriteString(` compile-workflows: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim permissions: contents: read @@ -366,7 +419,7 @@ jobs: await main(); zizmor-scan: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim needs: compile-workflows permissions: @@ -390,7 +443,7 @@ jobs: echo "✓ Zizmor security scan completed" secret-validation: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} runs-on: ubuntu-slim permissions: contents: read diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 2d97f9182a6..c6c2e22d53d 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -280,13 +280,15 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } yaml := string(content) - operationSkipCondition := `github.event_name != 'workflow_dispatch' || github.event.inputs.operation == ''` + // Updated condition now also excludes run_url to keep scheduled jobs from running during replay + operationSkipCondition := `github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')` operationRunCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation != ''` + applySafeOutputsCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != ''` const jobSectionSearchRange = 300 const runOpSectionSearchRange = 200 - // Jobs that should be disabled when operation is set + // Jobs that should be disabled when operation or run_url is set disabledJobs := []string{"close-expired-entities:", "compile-workflows:", "zizmor-scan:", "secret-validation:"} for _, job := range disabledJobs { // Find the if: condition for each job @@ -315,6 +317,22 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { t.Errorf("Job run_operation should have the activation condition %q", operationRunCondition) } } + + // apply_safe_outputs job should have its own activation condition triggered by run_url + applyIdx := strings.Index(yaml, "\n apply_safe_outputs:") + if applyIdx == -1 { + t.Errorf("Job apply_safe_outputs not found in generated workflow") + } else { + applySection := yaml[applyIdx : applyIdx+runOpSectionSearchRange] + if !strings.Contains(applySection, applySafeOutputsCondition) { + t.Errorf("Job apply_safe_outputs should have the activation condition %q in:\n%s", applySafeOutputsCondition, applySection) + } + } + + // Verify run_url input exists in workflow_dispatch + if !strings.Contains(yaml, "run_url:") { + t.Error("workflow_dispatch should include run_url input") + } } func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { From 80ac80b1aec1bbff50d3839c83d6475c8d51ac08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:48:38 +0000 Subject: [PATCH 2/3] fix: address code review feedback - use template literals in error messages Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2bcfe7a2-a8bc-4880-bb08-85f28f76d64e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/apply_safe_outputs_replay.cjs | 4 ++-- pkg/workflow/maintenance_workflow_test.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/apply_safe_outputs_replay.cjs b/actions/setup/js/apply_safe_outputs_replay.cjs index ea109f26aa3..b00b0ac871c 100644 --- a/actions/setup/js/apply_safe_outputs_replay.cjs +++ b/actions/setup/js/apply_safe_outputs_replay.cjs @@ -56,7 +56,7 @@ function parseRunUrl(runUrl) { return { runId: match[3], owner: match[1], repo: match[2] }; } - throw new Error(`${ERR_VALIDATION}: Cannot parse run ID from: ${trimmed}. ` + `Expected a plain run ID (digits only) or a GitHub Actions run URL ` + `(https://github.com/{owner}/{repo}/actions/runs/{runId}).`); + throw new Error(`${ERR_VALIDATION}: Cannot parse run ID from: ${trimmed}. Expected a plain run ID (digits only) or a GitHub Actions run URL (https://github.com/{owner}/{repo}/actions/runs/{runId}).`); } /** @@ -84,7 +84,7 @@ async function downloadAgentArtifact(runId, destDir, repoSlug) { const outputFile = path.join(destDir, AGENT_OUTPUT_FILENAME); if (!fs.existsSync(outputFile)) { - throw new Error(`${ERR_SYSTEM}: Agent output file not found at ${outputFile} after download. ` + `Ensure run ${runId} has an "agent" artifact containing ${AGENT_OUTPUT_FILENAME}.`); + throw new Error(`${ERR_SYSTEM}: Agent output file not found at ${outputFile} after download. Ensure run ${runId} has an "agent" artifact containing ${AGENT_OUTPUT_FILENAME}.`); } core.info(`✓ Agent artifact downloaded to ${outputFile}`); diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index c6c2e22d53d..1df2267da2b 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -280,7 +280,8 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } yaml := string(content) - // Updated condition now also excludes run_url to keep scheduled jobs from running during replay + // Updated condition now also excludes run_url so that scheduled jobs only run when + // neither operation nor run_url is provided in workflow_dispatch triggers operationSkipCondition := `github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')` operationRunCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation != ''` applySafeOutputsCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != ''` From f1cdba8ccd02b1334018f1eb7e4a7a822a5c6516 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:37:53 +0000 Subject: [PATCH 3/3] fix: use safe_outputs operation choice to trigger apply_safe_outputs job Add 'safe_outputs' to the operation choices dropdown so the apply_safe_outputs job is triggered via operation == 'safe_outputs', consistent with how other operations work. Restore simpler operation == '' conditions for scheduled jobs. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/16feceaf-d1f1-4502-a593-fbdd5ee78977 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 15 ++++++++------- pkg/workflow/maintenance_workflow.go | 19 ++++++++++--------- pkg/workflow/maintenance_workflow_test.go | 18 +++++++++++------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 0f4a60f060c..d2e1f05d0e1 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -48,8 +48,9 @@ on: - 'enable' - 'update' - 'upgrade' + - 'safe_outputs' run_url: - description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345)' + description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' required: false type: string default: '' @@ -58,7 +59,7 @@ permissions: {} jobs: close-expired-entities: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim permissions: discussions: write @@ -105,7 +106,7 @@ jobs: await main(); run_operation: - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && !github.event.repository.fork }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && !github.event.repository.fork }} runs-on: ubuntu-slim permissions: actions: write @@ -156,7 +157,7 @@ jobs: await main(); apply_safe_outputs: - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != '' && !github.event.repository.fork }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'safe_outputs' && !github.event.repository.fork }} runs-on: ubuntu-slim permissions: actions: read @@ -201,7 +202,7 @@ jobs: await main(); compile-workflows: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim permissions: contents: read @@ -241,7 +242,7 @@ jobs: await main(); zizmor-scan: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim needs: compile-workflows permissions: @@ -265,7 +266,7 @@ jobs: echo "✓ Zizmor security scan completed" secret-validation: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim permissions: contents: read diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 9d82ea97eb3..48a1d81f89c 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -199,8 +199,9 @@ on: - 'enable' - 'update' - 'upgrade' + - 'safe_outputs' run_url: - description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345)' + description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' required: false type: string default: '' @@ -209,7 +210,7 @@ permissions: {} jobs: close-expired-entities: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim permissions: discussions: write @@ -279,10 +280,10 @@ jobs: await main(); `) - // Add unified run_operation job for all dispatch operations + // Add unified run_operation job for all dispatch operations except safe_outputs yaml.WriteString(` run_operation: - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && !github.event.repository.fork }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && !github.event.repository.fork }} runs-on: ubuntu-slim permissions: actions: write @@ -327,10 +328,10 @@ jobs: await main(); `) - // Add apply_safe_outputs job for workflow_dispatch with run_url input + // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' yaml.WriteString(` apply_safe_outputs: - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != '' && !github.event.repository.fork }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'safe_outputs' && !github.event.repository.fork }} runs-on: ubuntu-slim permissions: actions: read @@ -382,7 +383,7 @@ jobs: // Add compile-workflows job yaml.WriteString(` compile-workflows: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim permissions: contents: read @@ -419,7 +420,7 @@ jobs: await main(); zizmor-scan: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim needs: compile-workflows permissions: @@ -443,7 +444,7 @@ jobs: echo "✓ Zizmor security scan completed" secret-validation: - if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')) }} + if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim permissions: contents: read diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 1df2267da2b..f7ddc36398d 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -280,16 +280,14 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } yaml := string(content) - // Updated condition now also excludes run_url so that scheduled jobs only run when - // neither operation nor run_url is provided in workflow_dispatch triggers - operationSkipCondition := `github.event_name != 'workflow_dispatch' || (github.event.inputs.operation == '' && github.event.inputs.run_url == '')` - operationRunCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation != ''` - applySafeOutputsCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.run_url != ''` + operationSkipCondition := `github.event_name != 'workflow_dispatch' || github.event.inputs.operation == ''` + operationRunCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs'` + applySafeOutputsCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'safe_outputs'` const jobSectionSearchRange = 300 const runOpSectionSearchRange = 200 - // Jobs that should be disabled when operation or run_url is set + // Jobs that should be disabled when operation is set disabledJobs := []string{"close-expired-entities:", "compile-workflows:", "zizmor-scan:", "secret-validation:"} for _, job := range disabledJobs { // Find the if: condition for each job @@ -306,6 +304,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } // run_operation job should NOT have the skip condition but should have its own activation condition + // and should exclude safe_outputs runOpIdx := strings.Index(yaml, "\n run_operation:") if runOpIdx == -1 { t.Errorf("Job run_operation not found in generated workflow") @@ -319,7 +318,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } - // apply_safe_outputs job should have its own activation condition triggered by run_url + // apply_safe_outputs job should be triggered when operation == 'safe_outputs' applyIdx := strings.Index(yaml, "\n apply_safe_outputs:") if applyIdx == -1 { t.Errorf("Job apply_safe_outputs not found in generated workflow") @@ -330,6 +329,11 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } + // Verify safe_outputs is an option in the operation choices + if !strings.Contains(yaml, "- 'safe_outputs'") { + t.Error("workflow_dispatch operation choices should include 'safe_outputs'") + } + // Verify run_url input exists in workflow_dispatch if !strings.Contains(yaml, "run_url:") { t.Error("workflow_dispatch should include run_url input")