diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index c4ffc44828c..e5af70fd633 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -37,6 +37,18 @@ on: schedule: - cron: "37 */2 * * *" # Every 2 hours (based on minimum expires: 1 days) workflow_dispatch: + inputs: + operation: + description: 'Optional maintenance operation to run' + required: false + type: choice + default: '' + options: + - '' + - 'disable' + - 'enable' + - 'update' + - 'upgrade' permissions: {} @@ -88,6 +100,57 @@ jobs: const { main } = require('/opt/gh-aw/actions/close_expired_pull_requests.cjs'); await main(); + run_operation: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && !github.event.repository.fork }} + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Build gh-aw + run: make build + + - name: Run operation + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_OPERATION: ${{ github.event.inputs.operation }} + GH_AW_CMD_PREFIX: ./gh-aw + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/run_operation_update_upgrade.cjs'); + await main(); + compile-workflows: if: ${{ !github.event.repository.fork }} runs-on: ubuntu-slim diff --git a/actions/setup/js/run_operation_update_upgrade.cjs b/actions/setup/js/run_operation_update_upgrade.cjs new file mode 100644 index 00000000000..4098e9ab2bb --- /dev/null +++ b/actions/setup/js/run_operation_update_upgrade.cjs @@ -0,0 +1,217 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); + +/** + * Format a UTC Date as YYYY-MM-DD-HH-MM-SS for use in branch names. + * Colons are not allowed in artifact filenames or branch names on some systems. + * + * @param {Date} date + * @returns {string} + */ +function formatTimestamp(date) { + /** @param {number} n */ + const pad = n => String(n).padStart(2, "0"); + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}-${pad(date.getUTCHours())}-${pad(date.getUTCMinutes())}-${pad(date.getUTCSeconds())}`; +} + +/** + * Run 'gh aw update', 'gh aw upgrade', 'gh aw disable', or 'gh aw enable', + * creating a pull request when needed for update/upgrade operations. + * + * For update/upgrade: runs with --no-compile so lock files are not modified. + * A pull request is opened for any changed files. The PR body instructs + * reviewers to recompile lock files after merging. + * + * For disable/enable: simply runs the command; no PR is created. + * + * Required environment variables: + * GH_TOKEN - GitHub token for gh CLI auth and git push + * GH_AW_OPERATION - 'update', 'upgrade', 'disable', or 'enable' + * GH_AW_CMD_PREFIX - Command prefix: './gh-aw' (dev) or 'gh aw' (release) + * + * @returns {Promise} + */ +async function main() { + const operation = process.env.GH_AW_OPERATION; + if (!operation) { + core.info("Skipping: no operation specified"); + return; + } + + const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; + const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean); + + // Handle enable/disable operations: run the command and finish (no PR needed) + if (operation === "disable" || operation === "enable") { + const fullCmd = [bin, ...prefixArgs, operation].join(" "); + core.info(`Running: ${fullCmd}`); + const exitCode = await exec.exec(bin, [...prefixArgs, operation]); + if (exitCode !== 0) { + throw new Error(`Command '${fullCmd}' failed with exit code ${exitCode}`); + } + core.info(`✓ All agentic workflows have been ${operation}d`); + return; + } + + // For update/upgrade, validate operation and proceed with PR creation if files changed + if (operation !== "update" && operation !== "upgrade") { + core.info(`Skipping: unknown operation '${operation}'`); + return; + } + + const isUpgrade = operation === "upgrade"; + + // Run gh aw update or gh aw upgrade (--no-compile: do not touch lock files) + const fullCmd = [bin, ...prefixArgs, operation, "--no-compile"].join(" "); + core.info(`Running: ${fullCmd}`); + const exitCode = await exec.exec(bin, [...prefixArgs, operation, "--no-compile"]); + if (exitCode !== 0) { + throw new Error(`Command '${fullCmd}' failed with exit code ${exitCode}`); + } + + // Check for changed files + const { stdout: statusOutput } = await exec.getExecOutput("git", ["status", "--porcelain"]); + + // Parse changed files from git status --porcelain format: "XY path" + // X and Y are 1-char each at positions 0-1, position 2 is a space, + // filename starts at position 3. Do NOT trim the full line before slicing. + const changedFiles = statusOutput + .split("\n") + .filter(line => line.length > 2) + .map(line => { + // "XY path" or "XY old -> new" for renames + const path = line.slice(3).trim(); + const parts = path.split(" -> "); + return path.includes(" -> ") ? (parts[parts.length - 1] ?? path) : path; + }) + .filter(file => file.length > 0); + + if (changedFiles.length === 0) { + core.info("✓ No changes detected - nothing to create a PR for"); + return; + } + + // Exclude .github/workflows/*.yml files: they cannot be modified by the + // GitHub Actions bot and including them would cause the PR checks to fail. + const filesToStage = changedFiles.filter(file => { + const lower = file.toLowerCase(); + return !(lower.startsWith(".github/workflows/") && (lower.endsWith(".yml") || lower.endsWith(".yaml"))); + }); + + if (filesToStage.length === 0) { + core.info("✓ No non-workflow files changed - nothing to create a PR for"); + return; + } + + core.info(`Found ${filesToStage.length} file(s) to include in PR:`); + for (const f of filesToStage) { + core.info(` ${f}`); + } + + // Configure git identity + await exec.exec("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"]); + await exec.exec("git", ["config", "user.name", "github-actions[bot]"]); + + // Create a new branch with a filesystem-safe timestamp (no colons) + const branchName = `aw/${operation}-${formatTimestamp(new Date())}`; + core.info(`Creating branch: ${branchName}`); + await exec.exec("git", ["checkout", "-b", branchName]); + + // Stage non-workflow-yml files only + for (const file of filesToStage) { + try { + await exec.exec("git", ["add", "--", file]); + } catch (error) { + core.warning(`Failed to stage '${file}': ${getErrorMessage(error)}`); + } + } + + // Verify staged content + const { stdout: stagedOutput } = await exec.getExecOutput("git", ["diff", "--cached", "--name-only"]); + if (!stagedOutput.trim()) { + core.info("✓ No staged changes - nothing to commit"); + return; + } + + const stagedFiles = stagedOutput + .split("\n") + .map(f => f.trim()) + .filter(Boolean); + + // Commit the changes + const commitMessage = isUpgrade ? "chore: upgrade agentic workflows" : "chore: update agentic workflows"; + await exec.exec("git", ["commit", "-m", commitMessage]); + + // Push to the new branch using a token-authenticated remote + const owner = context.repo.owner; + const repo = context.repo.repo; + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("Missing GitHub token: set GH_TOKEN or GITHUB_TOKEN to push changes and create a pull request for agentic workflow update/upgrade operations."); + } + const githubServerUrl = process.env.GITHUB_SERVER_URL || "https://github.com"; + let githubHost; + try { + githubHost = new URL(githubServerUrl).hostname || "github.com"; + } catch { + githubHost = "github.com"; + } + const remoteUrl = `https://x-access-token:${token}@${githubHost}/${owner}/${repo}.git`; + + try { + await exec.exec("git", ["remote", "remove", "aw-push"]); + } catch { + // Remote doesn't exist yet - that's fine + } + await exec.exec("git", ["remote", "add", "aw-push", remoteUrl]); + + try { + await exec.exec("git", ["push", "aw-push", branchName]); + } finally { + // Always clean up the temporary remote + try { + await exec.exec("git", ["remote", "remove", "aw-push"]); + } catch { + // Non-fatal + } + } + + // Build PR title and body + const prTitle = isUpgrade ? "[aw] Upgrade available" : "[aw] Updates available"; + const fileList = stagedFiles.map(f => `- \`${f}\``).join("\n"); + const operationLabel = isUpgrade ? "Upgrade" : "Update"; + const prBody = `## Agentic Workflows ${operationLabel} + +The \`gh aw ${operation} --no-compile\` command was run automatically and produced the following changes: + +${fileList} + +### ⚠️ Lock Files Need Recompilation + +After merging this PR, **recompile the lock files** using one of these methods: + +1. **Via @copilot**: Add a comment \`@copilot compile agentic workflows\` on this PR +2. **Via CLI**: Run \`gh aw compile --validate\` in your local checkout after merging +`; + + // Create the PR using gh CLI + core.info(`Creating PR: "${prTitle}"`); + const { stdout: prOutput } = await exec.getExecOutput("gh", ["pr", "create", "--title", prTitle, "--body", prBody, "--head", branchName, "--label", "agentic-workflows"], { + env: { ...process.env, GH_TOKEN: token }, + }); + + const prUrl = prOutput.trim(); + core.info(`✓ Created PR: ${prUrl}`); + core.notice(`Created PR: ${prUrl}`); + + await core.summary + .addHeading(prTitle, 2) + .addRaw(`Pull request created: [${prUrl}](${prUrl})\n\n`) + .addRaw(`**Changed files included in PR:**\n\n${fileList}\n\n`) + .addRaw(`> **Note**: Recompile lock files after merging via \`@copilot compile agentic workflows\` or \`gh aw compile\`.`) + .write(); +} + +module.exports = { main, formatTimestamp }; diff --git a/actions/setup/js/run_operation_update_upgrade.test.cjs b/actions/setup/js/run_operation_update_upgrade.test.cjs new file mode 100644 index 00000000000..2a7d05d8d96 --- /dev/null +++ b/actions/setup/js/run_operation_update_upgrade.test.cjs @@ -0,0 +1,363 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +/** Environment variables managed by tests */ +const TEST_ENV_VARS = ["GH_AW_OPERATION", "GH_AW_CMD_PREFIX", "GH_TOKEN", "GITHUB_TOKEN"]; + +describe("run_operation_update_upgrade", () => { + let mockCore; + let mockGithub; + let mockContext; + let mockExec; + let originalGlobals; + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Save original globals + originalGlobals = { + core: global.core, + github: global.github, + context: global.context, + exec: global.exec, + }; + + // Setup mock core module + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + notice: vi.fn(), + summary: { + addHeading: vi.fn().mockReturnThis(), + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Setup mock github + mockGithub = {}; + + // Setup mock context + mockContext = { + repo: { + owner: "testowner", + repo: "testrepo", + }, + }; + + // Setup mock exec module + mockExec = { + exec: vi.fn().mockResolvedValue(0), + getExecOutput: vi.fn(), + }; + + // Set globals for the module + global.core = mockCore; + global.github = mockGithub; + global.context = mockContext; + global.exec = mockExec; + }); + + afterEach(() => { + // Restore environment variables + for (const key of TEST_ENV_VARS) { + if (originalEnv[key] !== undefined) { + process.env[key] = originalEnv[key]; + } else { + delete process.env[key]; + } + } + + // Restore original globals + global.core = originalGlobals.core; + global.github = originalGlobals.github; + global.context = originalGlobals.context; + global.exec = originalGlobals.exec; + + vi.clearAllMocks(); + }); + + describe("formatTimestamp", () => { + it("formats a date as YYYY-MM-DD-HH-MM-SS", async () => { + const { formatTimestamp } = await import("./run_operation_update_upgrade.cjs"); + const date = new Date("2026-03-03T03:17:06.000Z"); + expect(formatTimestamp(date)).toBe("2026-03-03-03-17-06"); + }); + + it("pads single-digit values with zeros", async () => { + const { formatTimestamp } = await import("./run_operation_update_upgrade.cjs"); + const date = new Date("2026-01-05T09:05:03.000Z"); + expect(formatTimestamp(date)).toBe("2026-01-05-09-05-03"); + }); + }); + + describe("main - skips non-update/upgrade operations", () => { + it("skips when operation is not set", async () => { + delete process.env.GH_AW_OPERATION; + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping")); + expect(mockExec.exec).not.toHaveBeenCalled(); + }); + + it("skips when operation is unknown", async () => { + process.env.GH_AW_OPERATION = "unknown-operation"; + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping")); + expect(mockExec.exec).not.toHaveBeenCalled(); + }); + }); + + describe("main - disable/enable operations", () => { + it("runs gh aw disable and finishes without PR", async () => { + process.env.GH_AW_OPERATION = "disable"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["aw", "disable"]); + expect(mockExec.exec).toHaveBeenCalledTimes(1); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("disabled")); + expect(mockExec.getExecOutput).not.toHaveBeenCalled(); + }); + + it("runs gh aw enable and finishes without PR", async () => { + process.env.GH_AW_OPERATION = "enable"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["aw", "enable"]); + expect(mockExec.exec).toHaveBeenCalledTimes(1); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("enabled")); + expect(mockExec.getExecOutput).not.toHaveBeenCalled(); + }); + + it("runs ./gh-aw disable in dev mode", async () => { + process.env.GH_AW_OPERATION = "disable"; + process.env.GH_AW_CMD_PREFIX = "./gh-aw"; + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + expect(mockExec.exec).toHaveBeenCalledWith("./gh-aw", ["disable"]); + expect(mockExec.exec).toHaveBeenCalledTimes(1); + }); + + it("propagates error when disable command fails", async () => { + process.env.GH_AW_OPERATION = "disable"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + + mockExec.exec = vi.fn().mockRejectedValue(new Error("Command failed")); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await expect(main()).rejects.toThrow("Command failed"); + }); + + it("throws when disable exits with non-zero code", async () => { + process.env.GH_AW_OPERATION = "disable"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + + mockExec.exec = vi.fn().mockResolvedValue(1); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await expect(main()).rejects.toThrow("exit code 1"); + }); + }); + + describe("main - no changes after command", () => { + it("finishes without creating PR when no files changed", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + // git status shows no changes + mockExec.getExecOutput = vi.fn().mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 }); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No changes detected")); + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["aw", "update", "--no-compile"]); + }); + + it("finishes without PR when only workflow yml files changed", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + mockExec.getExecOutput = vi.fn().mockResolvedValueOnce({ + stdout: " M .github/workflows/agentics-maintenance.yml\n", + stderr: "", + exitCode: 0, + }); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No non-workflow files changed")); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", expect.arrayContaining(["add"])); + }); + }); + + describe("main - creates PR when files changed", () => { + it("creates PR for update operation with changes", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + const getExecOutputMock = vi.fn(); + // git status + getExecOutputMock.mockResolvedValueOnce({ + stdout: " M .github/workflows/my-workflow.md\n", + stderr: "", + exitCode: 0, + }); + // git diff --cached --name-only + getExecOutputMock.mockResolvedValueOnce({ + stdout: ".github/workflows/my-workflow.md\n", + stderr: "", + exitCode: 0, + }); + // gh pr create + getExecOutputMock.mockResolvedValueOnce({ + stdout: "https://github.com/testowner/testrepo/pull/1\n", + stderr: "", + exitCode: 0, + }); + mockExec.getExecOutput = getExecOutputMock; + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + // Verify gh aw update --no-compile was run + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["aw", "update", "--no-compile"]); + // Verify branch was created + expect(mockExec.exec).toHaveBeenCalledWith("git", expect.arrayContaining(["checkout", "-b", expect.stringContaining("aw/update-")])); + // Verify file was staged + expect(mockExec.exec).toHaveBeenCalledWith("git", ["add", "--", ".github/workflows/my-workflow.md"]); + // Verify commit was made + expect(mockExec.exec).toHaveBeenCalledWith("git", ["commit", "-m", "chore: update agentic workflows"]); + // Verify PR title + expect(getExecOutputMock).toHaveBeenCalledWith("gh", expect.arrayContaining(["pr", "create", "--title", "[aw] Updates available", "--label", "agentic-workflows"]), expect.anything()); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Created PR")); + }); + + it("creates PR for upgrade operation with correct title", async () => { + process.env.GH_AW_OPERATION = "upgrade"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + const getExecOutputMock = vi.fn(); + // git status + getExecOutputMock.mockResolvedValueOnce({ + stdout: " M .github/agents/agentic-workflows.agent.md\n M .github/workflows/agentics-maintenance.yml\n", + stderr: "", + exitCode: 0, + }); + // git diff --cached --name-only + getExecOutputMock.mockResolvedValueOnce({ + stdout: ".github/agents/agentic-workflows.agent.md\n", + stderr: "", + exitCode: 0, + }); + // gh pr create + getExecOutputMock.mockResolvedValueOnce({ + stdout: "https://github.com/testowner/testrepo/pull/2\n", + stderr: "", + exitCode: 0, + }); + mockExec.getExecOutput = getExecOutputMock; + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + // Verify gh aw upgrade --no-compile was run + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["aw", "upgrade", "--no-compile"]); + // Verify correct commit message + expect(mockExec.exec).toHaveBeenCalledWith("git", ["commit", "-m", "chore: upgrade agentic workflows"]); + // Verify PR title is "[aw] Upgrade available" + expect(getExecOutputMock).toHaveBeenCalledWith("gh", expect.arrayContaining(["pr", "create", "--title", "[aw] Upgrade available", "--label", "agentic-workflows"]), expect.anything()); + // Verify workflow yml was NOT staged + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["add", "--", ".github/workflows/agentics-maintenance.yml"]); + }); + + it("uses ./gh-aw as binary in dev mode", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "./gh-aw"; + process.env.GH_TOKEN = "test-token"; + + const getExecOutputMock = vi.fn(); + getExecOutputMock + .mockResolvedValueOnce({ stdout: " M .github/workflows/my-workflow.md\n", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: ".github/workflows/my-workflow.md\n", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: "https://github.com/testowner/testrepo/pull/3\n", stderr: "", exitCode: 0 }); + mockExec.getExecOutput = getExecOutputMock; + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + // Verify binary is ./gh-aw (no prefix args) with --no-compile + expect(mockExec.exec).toHaveBeenCalledWith("./gh-aw", ["update", "--no-compile"]); + }); + }); + + describe("main - handles errors", () => { + it("propagates error when command fails", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + mockExec.exec = vi.fn().mockRejectedValue(new Error("Command failed")); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await expect(main()).rejects.toThrow("Command failed"); + }); + + it("throws when update exits with non-zero code", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + mockExec.exec = vi.fn().mockResolvedValue(1); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await expect(main()).rejects.toThrow("exit code 1"); + }); + + it("warns and continues when staging a file fails", async () => { + process.env.GH_AW_OPERATION = "update"; + process.env.GH_AW_CMD_PREFIX = "gh aw"; + process.env.GH_TOKEN = "test-token"; + + const getExecOutputMock = vi.fn(); + getExecOutputMock + .mockResolvedValueOnce({ + stdout: " M .github/workflows/my-workflow.md\n?? .github/aw/actions-lock.json\n", + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ stdout: ".github/aw/actions-lock.json\n", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: "https://github.com/testowner/testrepo/pull/4\n", stderr: "", exitCode: 0 }); + mockExec.getExecOutput = getExecOutputMock; + + // git add fails for the first file, succeeds for others + mockExec.exec = vi.fn().mockImplementation(async (cmd, args) => { + if (cmd === "git" && args[0] === "add" && args[2] === ".github/workflows/my-workflow.md") { + throw new Error("git add failed"); + } + return 0; + }); + + const { main } = await import("./run_operation_update_upgrade.cjs"); + await main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to stage")); + }); + }); +}); diff --git a/docs/src/content/docs/guides/ephemerals.md b/docs/src/content/docs/guides/ephemerals.md index 3af41a9a3a6..faa712098ca 100644 --- a/docs/src/content/docs/guides/ephemerals.md +++ b/docs/src/content/docs/guides/ephemerals.md @@ -87,6 +87,10 @@ The maintenance workflow searches for items with this expiration format (checked See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/) for complete documentation. +### Manual Maintenance Operations + +The generated `agentics-maintenance.yml` workflow also supports manual bulk operations via `workflow_dispatch`. Admin or maintainer users can trigger it from the GitHub Actions UI or the CLI to disable or enable all agentic workflows in the repository at once. The operation is restricted to admin and maintainer roles and is not available on forks. + ### Close Older Issues Automatically close older issues with the same workflow-id marker when creating new ones. This keeps your issues focused on the latest information. diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index b4b83a369fe..05f921554d9 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -135,6 +135,18 @@ on: schedule: - cron: "` + cronSchedule + `" # ` + scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(minExpiresDays) + ` days) workflow_dispatch: + inputs: + operation: + description: 'Optional maintenance operation to run' + required: false + type: choice + default: '' + options: + - '' + - 'disable' + - 'enable' + - 'update' + - 'upgrade' permissions: {} @@ -212,6 +224,88 @@ jobs: await main(); `) + // Add unified run_operation job for all dispatch operations + yaml.WriteString(` + run_operation: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && !github.event.repository.fork }} + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: ` + GetActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: /opt/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + GetActionPin("actions/github-script") + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_team_member.cjs'); + await main(); + +`) + + if actionMode == ActionModeDev { + yaml.WriteString(` - name: Setup Go + uses: ` + GetActionPin("actions/setup-go") + ` + with: + go-version-file: go.mod + cache: true + + - name: Build gh-aw + run: make build + + - name: Run operation + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_OPERATION: ${{ github.event.inputs.operation }} + GH_AW_CMD_PREFIX: ./gh-aw + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/run_operation_update_upgrade.cjs'); + await main(); +`) + } else { + extensionRef := version + if actionTag != "" { + extensionRef = actionTag + } + yaml.WriteString(` - name: Install gh-aw extension + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh extension install github/gh-aw@` + extensionRef + ` + + - name: Run operation + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_OPERATION: ${{ github.event.inputs.operation }} + GH_AW_CMD_PREFIX: gh aw + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/run_operation_update_upgrade.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