diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 334d0b7954e..828732d7e61 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -138,6 +138,11 @@ "version": "v4.1.0", "sha": "4907a6ddec9925e35a0a9e82d7399ccc52663121" }, + "docker/metadata-action@v6": { + "repo": "docker/metadata-action", + "version": "v6", + "sha": "80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9" + }, "docker/metadata-action@v6.0.0": { "repo": "docker/metadata-action", "version": "v6.0.0", diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 66286a655dc..3655d561585 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -763,7 +763,7 @@ jobs: with: destination: ${{ runner.temp }}/gh-aw/actions - - name: Check for out-of-sync workflows and create issue if needed + - name: Check for out-of-sync workflows and create issue or pull request if needed uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 345e02e8eff..d9aaa5d6eba 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"6a6bd39e2339b2b176862a0fceb3dc70c2440fe79b8689f4e476134a57bed33a","strict":true,"agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"anchore/sbom-action","sha":"e22c389904149dbc22b58101806040fa8d37a610","version":"v0.24.0"},{"repo":"docker/build-push-action","sha":"bcafcacb16a39f128d818304e6c9c0c18556b85f","version":"v7.1.0"},{"repo":"docker/login-action","sha":"4907a6ddec9925e35a0a9e82d7399ccc52663121","version":"v4.1.0"},{"repo":"docker/metadata-action","sha":"030e881283bb7a6894de51c315a6bfe6a94e05cf","version":"v6.0.0 (source v6)"},{"repo":"docker/setup-buildx-action","sha":"4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd","version":"v4.0.0 (source v4)"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"anchore/sbom-action","sha":"e22c389904149dbc22b58101806040fa8d37a610","version":"v0.24.0"},{"repo":"docker/build-push-action","sha":"bcafcacb16a39f128d818304e6c9c0c18556b85f","version":"v7.1.0"},{"repo":"docker/login-action","sha":"4907a6ddec9925e35a0a9e82d7399ccc52663121","version":"v4.1.0"},{"repo":"docker/metadata-action","sha":"80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9","version":"v6"},{"repo":"docker/setup-buildx-action","sha":"4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd","version":"v4.0.0 (source v4)"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -49,7 +49,7 @@ # - anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 # - docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 # - docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 -# - docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 (source v6) +# - docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 # - docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 (source v4) # # Container images used: @@ -1541,7 +1541,7 @@ jobs: username: ${{ github.actor }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 (source v6) + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: ghcr.io/${{ github.repository }} tags: | diff --git a/actions/setup/js/check_workflow_recompile_needed.cjs b/actions/setup/js/check_workflow_recompile_needed.cjs index c1860b26f5a..1e6a07e7abb 100644 --- a/actions/setup/js/check_workflow_recompile_needed.cjs +++ b/actions/setup/js/check_workflow_recompile_needed.cjs @@ -2,12 +2,254 @@ /// const { getErrorMessage } = require("./error_helpers.cjs"); -const { generateFooterWithMessages, getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, generateXMLMarker, getDetectionCautionAlert } = require("./messages_footer.cjs"); +const { getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, generateXMLMarker, getDetectionCautionAlert } = require("./messages_footer.cjs"); const fs = require("fs"); +const { getGitAuthEnv } = require("./git_helpers.cjs"); +const { resolvePullRequestRepo } = require("./pr_helpers.cjs"); +const { pushSignedCommits } = require("./push_signed_commits.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); +const RECOMPILE_ISSUE_TITLE = "[aw] agentic workflows out of sync"; +const RECOMPILE_PR_TITLE = "[aw] recompile agentic workflows"; +const RECOMPILE_PR_BRANCH = "aw/recompile-workflows"; + +function shouldCreatePullRequest() { + return getRecompileToken() !== ""; +} + +async function getEffectiveBaseBranch(owner, repo) { + const { effectiveBaseBranch } = await resolvePullRequestRepo(github, owner, repo, undefined); + return effectiveBaseBranch || "main"; +} + +function getRecompileToken() { + return process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN || ""; +} + +function logConfiguration(createPullRequest) { + core.info(`Workflow recompile mode: ${createPullRequest ? "pull-request" : "issue"}`); + core.info(`Configured maintenance token present: ${getRecompileToken() !== ""}`); +} + +function requireRecompileToken() { + const token = getRecompileToken(); + if (!token) { + throw new Error("Missing configured maintenance GitHub token secret for maintenance compile pull request creation"); + } + return token; +} + +function buildRecompilePullRequestBody(changedFiles, repository, runUrl, linkedIssueNumber) { + const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Agentic Maintenance"; + const footer = getFooterWorkflowRecompileMessage({ workflowName, runUrl, repository }); + const xmlMarker = generateXMLMarker(workflowName, runUrl); + const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); + const cautionPrefix = detectionCaution ? `${detectionCaution}\n\n` : ""; + const linkedIssueLine = linkedIssueNumber ? `Fixes #${linkedIssueNumber}\n\n` : ""; + const fileList = changedFiles.map(file => `- \`${file}\``).join("\n"); + + return `${cautionPrefix}## Workflow Recompilation + +This automated maintenance run detected generated workflow changes and prepared this pull request to update the lock files. + +${linkedIssueLine}## Changed Files + +${fileList} + +--- +${footer} + +${xmlMarker} +`; +} + +async function getChangedLockFiles() { + // Compare the current working tree against HEAD to capture the lock files + // changed by this maintenance compile run before any branch operations. + const { stdout } = await exec.getExecOutput("git", ["diff", "--name-only", ".github/workflows/*.lock.yml"], { + ignoreReturnCode: true, + }); + return stdout + .split("\n") + .map(file => file.trim()) + .filter(Boolean); +} + +async function getLocalHeadSha() { + const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); + return stdout.trim(); +} + +async function getRemoteBranchHead(branchName) { + const { stdout, exitCode, stderr } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { + ignoreReturnCode: true, + }); + if (exitCode !== 0) { + core.info(`Could not query remote branch ${branchName}: ${stderr.trim() || `exit code ${exitCode}`}`); + return ""; + } + const trimmed = stdout.trim(); + if (!trimmed) { + core.info(`Remote branch ${branchName} does not exist yet`); + return ""; + } + const remoteHead = trimmed.split(/\s+/)[0] || ""; + core.info(`Remote branch ${branchName} currently points to ${remoteHead}`); + return remoteHead; +} + +async function fetchRemoteBranch(branchName) { + core.info(`Fetching remote branch ${branchName} for comparison`); + await exec.exec("git", ["fetch", "origin", `refs/heads/${branchName}:refs/remotes/origin/${branchName}`]); +} + +async function filterFilesNeedingUpdate(comparisonRef, changedFiles, workspaceDir) { + const filesToUpdate = []; + for (const file of changedFiles) { + const workingTreePath = `${workspaceDir}/${file}`; + const workingTreeContent = fs.readFileSync(workingTreePath, "utf8"); + const { stdout, exitCode } = await exec.getExecOutput("git", ["show", `${comparisonRef}:${file}`], { + ignoreReturnCode: true, + }); + if (exitCode !== 0) { + core.info(`Remote ref ${comparisonRef} does not contain ${file}; scheduling update`); + filesToUpdate.push(file); + continue; + } + if (stdout !== workingTreeContent) { + core.info(`Detected updated compiled workflow content for ${file}`); + filesToUpdate.push(file); + continue; + } + core.info(`Compiled workflow file ${file} already matches ${comparisonRef}`); + } + return filesToUpdate; +} + +async function stageFiles(files) { + if (!Array.isArray(files) || files.length === 0) { + return; + } + await exec.exec("git", ["add", "--", ...files]); +} + +async function prepareAndPushRecompileBranch(owner, repo, changedFiles) { + const token = requireRecompileToken(); + const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); + const baseHead = await getLocalHeadSha(); + core.info(`Current repository HEAD before maintenance branch commit: ${baseHead}`); + + const remoteHead = await getRemoteBranchHead(RECOMPILE_PR_BRANCH); + let filesToCommit = changedFiles; + let baseRef = baseHead; + if (remoteHead) { + await fetchRemoteBranch(RECOMPILE_PR_BRANCH); + filesToCommit = await filterFilesNeedingUpdate(`refs/remotes/origin/${RECOMPILE_PR_BRANCH}`, changedFiles, workspaceDir); + baseRef = remoteHead; + } + + core.info(`Preparing maintenance branch ${RECOMPILE_PR_BRANCH}`); + await exec.exec("git", ["checkout", "-B", RECOMPILE_PR_BRANCH]); + + if (filesToCommit.length === 0) { + core.info("Existing maintenance branch already contains the latest compiled workflow lock files"); + return { pushed: false }; + } + + await stageFiles(filesToCommit); + core.info(`Staging ${filesToCommit.length} workflow lock file(s): ${filesToCommit.join(", ")}`); + await exec.exec("git", ["commit", "-m", "chore: recompile agentic workflows"]); + + core.info(`Pushing maintenance branch ${RECOMPILE_PR_BRANCH} via signed commit helper (baseRef=${baseRef})`); + await pushSignedCommits({ + githubClient: github, + owner, + repo, + branch: RECOMPILE_PR_BRANCH, + baseRef, + cwd: workspaceDir, + gitAuthEnv: getGitAuthEnv(token), + allowGitPushFallback: false, + }); + return { pushed: true }; +} + +async function findExistingRecompilePullRequest(owner, repo) { + core.info(`Searching for an existing maintenance PR from branch ${owner}:${RECOMPILE_PR_BRANCH}`); + const result = await github.rest.pulls.list({ + owner, + repo, + state: "open", + head: `${owner}:${RECOMPILE_PR_BRANCH}`, + per_page: 1, + }); + return result.data[0] || null; +} + +async function findExistingRecompileIssue(owner, repo) { + const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${RECOMPILE_ISSUE_TITLE}"`; + + core.info(`Searching for existing issue with title: "${RECOMPILE_ISSUE_TITLE}"`); + const searchResult = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }); + return searchResult.data.total_count > 0 ? searchResult.data.items[0] : null; +} + +async function handlePullRequest(owner, repo, changedFiles) { + const repository = `${owner}/${repo}`; + const runUrl = buildWorkflowRunUrl(context, context.repo); + core.info(`Preparing maintenance PR for ${repository}`); + const existingIssue = await findExistingRecompileIssue(owner, repo); + if (existingIssue) { + core.info(`Found existing issue #${existingIssue.number} to link from maintenance PR`); + } else { + core.info("No existing workflow recompile issue found to link from maintenance PR"); + } + const { pushed } = await prepareAndPushRecompileBranch(owner, repo, changedFiles); + const pullRequestBody = buildRecompilePullRequestBody(changedFiles, repository, runUrl, existingIssue?.number); + + const existingPR = await findExistingRecompilePullRequest(owner, repo); + if (existingPR) { + core.info(`Found existing pull request #${existingPR.number}: ${existingPR.html_url}`); + core.info(`Updating existing pull request #${existingPR.number} body`); + await github.rest.pulls.update({ + owner, + repo, + pull_number: existingPR.number, + body: pullRequestBody, + }); + const updateMessage = pushed ? "Updated existing pull request branch (avoiding duplicate)" : "Existing pull request already had the latest branch contents"; + core.info(updateMessage); + await core.summary + .addHeading("Workflow Recompilation Needed", 2) + .addRaw( + pushed + ? `Updated existing pull request [#${existingPR.number}](${existingPR.html_url}) with the latest compiled workflow changes.` + : `Existing pull request [#${existingPR.number}](${existingPR.html_url}) already contains the latest compiled workflow changes.` + ) + .write(); + return; + } + + core.info(`Creating maintenance pull request against repository default branch with ${changedFiles.length} changed file(s)`); + const defaultBranch = await getEffectiveBaseBranch(owner, repo); + const pullRequest = await github.rest.pulls.create({ + owner, + repo, + title: RECOMPILE_PR_TITLE, + head: RECOMPILE_PR_BRANCH, + base: defaultBranch, + body: pullRequestBody, + }); + + core.info(`✓ Created pull request #${pullRequest.data.number}: ${pullRequest.data.html_url}`); + await core.summary.addHeading("Workflow Recompilation Needed", 2).addRaw(`Created pull request [#${pullRequest.data.number}](${pullRequest.data.html_url}) to update compiled workflow lock files.`).write(); +} + /** - * Check if workflows need recompilation and create an issue if needed. + * Check if workflows need recompilation and create an issue or pull request if needed. * This script: * 1. Checks if there are out-of-sync workflow lock files * 2. Searches for existing open issues about recompiling workflows @@ -18,8 +260,10 @@ const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); async function main() { const owner = context.repo.owner; const repo = context.repo.repo; + const createPullRequest = shouldCreatePullRequest(); core.info("Checking for out-of-sync workflow lock files"); + logConfiguration(createPullRequest); // Execute git diff to check for changes in lock files let diffOutput = ""; @@ -54,6 +298,9 @@ async function main() { } core.info("⚠ Detected out-of-sync workflow lock files"); + core.info(`Workflow diff size from detection step: ${diffOutput.length} byte(s)`); + const changedFiles = await getChangedLockFiles(); + core.info(`Changed workflow lock file count: ${changedFiles.length}`); // Capture the actual diff for the issue body let detailedDiff = ""; @@ -68,21 +315,17 @@ async function main() { } catch (error) { core.warning(`Could not capture detailed diff: ${getErrorMessage(error)}`); } + core.info(`Detailed workflow diff captured: ${detailedDiff.length} byte(s)`); - // Search for existing open issue about workflow recompilation - const issueTitle = "[aw] agentic workflows out of sync"; - const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${issueTitle}"`; - - core.info(`Searching for existing issue with title: "${issueTitle}"`); + if (createPullRequest) { + requireRecompileToken(); + await handlePullRequest(owner, repo, changedFiles); + return; + } try { - const searchResult = await github.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 1, - }); - - if (searchResult.data.total_count > 0) { - const existingIssue = searchResult.data.items[0]; + const existingIssue = await findExistingRecompileIssue(owner, repo); + if (existingIssue) { core.info(`Found existing issue #${existingIssue.number}: ${existingIssue.html_url}`); core.info("Skipping issue creation (avoiding duplicate)"); @@ -175,7 +418,7 @@ async function main() { const newIssue = await github.rest.issues.create({ owner, repo, - title: issueTitle, + title: RECOMPILE_ISSUE_TITLE, body: issueBody, labels: ["agentic-workflows", "maintenance"], }); @@ -190,4 +433,4 @@ async function main() { } } -module.exports = { main }; +module.exports = { main, buildRecompilePullRequestBody, shouldCreatePullRequest }; diff --git a/actions/setup/js/check_workflow_recompile_needed.test.cjs b/actions/setup/js/check_workflow_recompile_needed.test.cjs index d5c84257c3f..68f75570d7d 100644 --- a/actions/setup/js/check_workflow_recompile_needed.test.cjs +++ b/actions/setup/js/check_workflow_recompile_needed.test.cjs @@ -3,6 +3,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; import path from "path"; import os from "os"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); describe("check_workflow_recompile_needed", () => { let mockCore; @@ -11,15 +14,24 @@ describe("check_workflow_recompile_needed", () => { let mockExec; let originalGlobals; let originalEnv; + let workspaceDir; const testPromptsDir = path.join(os.tmpdir(), "gh-aw-test", "prompts"); const templatePath = path.join(testPromptsDir, "workflow_recompile_issue.md"); beforeEach(() => { // Save original environment - originalEnv = process.env.GH_AW_PROMPTS_DIR; + originalEnv = { + GH_AW_PROMPTS_DIR: process.env.GH_AW_PROMPTS_DIR, + GH_AW_MAINTENANCE_GITHUB_TOKEN: process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN, + GITHUB_WORKSPACE: process.env.GITHUB_WORKSPACE, + }; // Set test prompts directory process.env.GH_AW_PROMPTS_DIR = testPromptsDir; + workspaceDir = path.join(os.tmpdir(), "gh-aw-test", "workspace"); + process.env.GITHUB_WORKSPACE = workspaceDir; + fs.mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true }); + fs.writeFileSync(path.join(workspaceDir, ".github", "workflows", "example.lock.yml"), "name: example\n", "utf8"); // Create the template file for testing const templateDir = path.dirname(templatePath); @@ -119,7 +131,30 @@ The following workflow lock files have changes: create: vi.fn(), createComment: vi.fn(), }, + pulls: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + git: { + createRef: vi.fn(), + }, }, + graphql: vi.fn().mockImplementation(query => { + if (String(query).includes("createCommitOnBranch")) { + return Promise.resolve({ + createCommitOnBranch: { + commit: { oid: "signed-oid" }, + }, + }); + } + return Promise.resolve({ + repository: { + id: "repo-id", + defaultBranchRef: { name: "main" }, + }, + }); + }), }; // Setup mock context @@ -132,6 +167,7 @@ The following workflow lock files have changes: payload: { repository: { html_url: "https://github.com/testowner/testrepo", + default_branch: "main", }, }, }; @@ -139,6 +175,7 @@ The following workflow lock files have changes: // Setup mock exec module mockExec = { exec: vi.fn(), + getExecOutput: vi.fn(), }; // Set globals for the module @@ -150,11 +187,21 @@ The following workflow lock files have changes: afterEach(() => { // Restore environment variable - if (originalEnv !== undefined) { - process.env.GH_AW_PROMPTS_DIR = originalEnv; + if (originalEnv.GH_AW_PROMPTS_DIR !== undefined) { + process.env.GH_AW_PROMPTS_DIR = originalEnv.GH_AW_PROMPTS_DIR; } else { delete process.env.GH_AW_PROMPTS_DIR; } + if (originalEnv.GH_AW_MAINTENANCE_GITHUB_TOKEN !== undefined) { + process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN = originalEnv.GH_AW_MAINTENANCE_GITHUB_TOKEN; + } else { + delete process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN; + } + if (originalEnv.GITHUB_WORKSPACE !== undefined) { + process.env.GITHUB_WORKSPACE = originalEnv.GITHUB_WORKSPACE; + } else { + delete process.env.GITHUB_WORKSPACE; + } // Clean up the test directory const testDir = path.join(os.tmpdir(), "gh-aw-test"); @@ -168,13 +215,15 @@ The following workflow lock files have changes: global.context = originalGlobals.context; global.exec = originalGlobals.exec; - // Clear all mocks + // Clear mock state and reset the module cache because each test dynamically imports the CJS module. vi.clearAllMocks(); + vi.resetModules(); }); it("should report no changes when workflows are up to date", async () => { // Mock exec to return no changes (empty diff output) mockExec.exec.mockResolvedValue(0); + mockExec.getExecOutput.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 }); const { main } = await import("./check_workflow_recompile_needed.cjs"); await main(); @@ -198,6 +247,13 @@ The following workflow lock files have changes: } return 0; }); + mockExec.getExecOutput.mockImplementation(async (cmd, args) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --name-only .github/workflows/*.lock.yml") { + return { stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); // Mock search to return existing issue mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ @@ -242,6 +298,13 @@ The following workflow lock files have changes: } return 0; }); + mockExec.getExecOutput.mockImplementation(async (cmd, args) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --name-only .github/workflows/*.lock.yml") { + return { stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); // Mock search to return no existing issue mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ @@ -274,10 +337,361 @@ The following workflow lock files have changes: it("should handle errors gracefully", async () => { // Mock exec to throw error mockExec.exec.mockRejectedValue(new Error("Git command failed")); + mockExec.getExecOutput.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 }); const { main } = await import("./check_workflow_recompile_needed.cjs"); await expect(main()).rejects.toThrow("Git command failed"); expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to check for workflow changes")); }); + + it("should create a pull request when PR mode is enabled", async () => { + process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN = "ghs_test_token"; + + mockExec.exec.mockImplementation(async (cmd, args, options) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --exit-code .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("diff content")); + return 1; + } + if (joinedArgs === "diff .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("detailed diff content")); + return 0; + } + if (joinedArgs === "diff --cached --name-only") { + options?.listeners?.stdout?.(Buffer.from(".github/workflows/example.lock.yml\n")); + return 0; + } + if (joinedArgs === "cat-file blob blobhash") { + options?.listeners?.stdout?.(Buffer.from("name: example\n")); + return 0; + } + return 0; + }); + mockExec.getExecOutput.mockImplementation(async (cmd, args) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --name-only .github/workflows/*.lock.yml") { + return { stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-parse HEAD") { + return { stdout: "base-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "ls-remote origin refs/heads/aw/recompile-workflows") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-list --parents --topo-order --reverse base-head-sha..HEAD") { + return { stdout: "commit-sha base-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "diff-tree -r --raw commit-sha") { + return { stdout: ":100644 100644 oldhash blobhash M\t.github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-parse commit-sha^") { + return { stdout: "base-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "log -1 --format=%B commit-sha") { + return { stdout: "chore: recompile agentic workflows\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 42, + html_url: "https://github.com/testowner/testrepo/issues/42", + }, + ], + }, + }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + mockGithub.rest.pulls.create.mockResolvedValue({ + data: { + number: 44, + html_url: "https://github.com/testowner/testrepo/pull/44", + }, + }); + + const { main } = await import("./check_workflow_recompile_needed.cjs"); + await main(); + + expect(mockGithub.rest.pulls.list).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + state: "open", + head: "testowner:aw/recompile-workflows", + per_page: 1, + }); + expect(mockGithub.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "testowner", + repo: "testrepo", + title: "[aw] recompile agentic workflows", + head: "aw/recompile-workflows", + base: "main", + }) + ); + const createdBody = mockGithub.rest.pulls.create.mock.calls[0][0].body; + expect(createdBody).toContain("Workflow Recompilation"); + expect(createdBody).toContain("Fixes #42"); + expect(createdBody).toContain(".github/workflows/example.lock.yml"); + expect(createdBody).not.toContain("detailed diff content"); + expect(mockGithub.rest.git.createRef).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + ref: "refs/heads/aw/recompile-workflows", + sha: "base-head-sha", + }); + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + }); + + it("should require signed commits when creating a maintenance pull request", async () => { + process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN = "ghs_test_token"; + + const pushSignedCommitsModule = require("./push_signed_commits.cjs"); + const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("signed-oid"); + + mockExec.exec.mockImplementation(async (cmd, args, options) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --exit-code .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("diff content")); + return 1; + } + if (joinedArgs === "diff .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("detailed diff content")); + return 0; + } + return 0; + }); + mockExec.getExecOutput.mockImplementation(async (cmd, args) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --name-only .github/workflows/*.lock.yml") { + return { stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-parse HEAD") { + return { stdout: "base-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "ls-remote origin refs/heads/aw/recompile-workflows") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 0, + items: [], + }, + }); + mockGithub.rest.pulls.list.mockResolvedValue({ data: [] }); + mockGithub.rest.pulls.create.mockResolvedValue({ + data: { + number: 44, + html_url: "https://github.com/testowner/testrepo/pull/44", + }, + }); + + const { main } = await import("./check_workflow_recompile_needed.cjs"); + await main(); + + expect(pushSignedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "testowner", + repo: "testrepo", + branch: "aw/recompile-workflows", + allowGitPushFallback: false, + }) + ); + }); + + it("should reuse an existing pull request when PR mode is enabled", async () => { + process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN = "ghs_test_token"; + + mockExec.exec.mockImplementation(async (cmd, args, options) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --exit-code .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("diff content")); + return 1; + } + if (joinedArgs === "diff .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("detailed diff content")); + return 0; + } + if (joinedArgs === "diff --cached --name-only") { + options?.listeners?.stdout?.(Buffer.from(".github/workflows/example.lock.yml\n")); + return 0; + } + if (joinedArgs === "cat-file blob blobhash") { + options?.listeners?.stdout?.(Buffer.from("name: example\n")); + return 0; + } + return 0; + }); + mockExec.getExecOutput.mockImplementation(async (cmd, args) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --name-only .github/workflows/*.lock.yml") { + return { stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-parse HEAD") { + return { stdout: "base-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "ls-remote origin refs/heads/aw/recompile-workflows") { + return { stdout: "remote-head-sha\trefs/heads/aw/recompile-workflows\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "show refs/remotes/origin/aw/recompile-workflows:.github/workflows/example.lock.yml") { + return { stdout: "older: true\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-list --parents --topo-order --reverse remote-head-sha..HEAD") { + return { stdout: "commit-sha remote-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "diff-tree -r --raw commit-sha") { + return { stdout: ":100644 100644 oldhash blobhash M\t.github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "log -1 --format=%B commit-sha") { + return { stdout: "chore: recompile agentic workflows\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 42, + html_url: "https://github.com/testowner/testrepo/issues/42", + }, + ], + }, + }); + mockGithub.rest.pulls.list.mockResolvedValue({ + data: [ + { + number: 45, + html_url: "https://github.com/testowner/testrepo/pull/45", + }, + ], + }); + + const { main } = await import("./check_workflow_recompile_needed.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Found existing pull request")); + expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "testowner", + repo: "testrepo", + pull_number: 45, + }) + ); + const updatedBody = mockGithub.rest.pulls.update.mock.calls[0][0].body; + expect(updatedBody).toContain("Workflow Recompilation"); + expect(updatedBody).toContain("Fixes #42"); + expect(updatedBody).toContain(".github/workflows/example.lock.yml"); + expect(updatedBody).not.toContain("detailed diff content"); + expect(mockGithub.rest.git.createRef).not.toHaveBeenCalled(); + expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + }); + + it("should skip signed commit push when the existing maintenance branch already matches", async () => { + process.env.GH_AW_MAINTENANCE_GITHUB_TOKEN = "ghs_test_token"; + + mockExec.exec.mockImplementation(async (cmd, args, options) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --exit-code .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("diff content")); + return 1; + } + if (joinedArgs === "diff .github/workflows/*.lock.yml") { + options?.listeners?.stdout?.(Buffer.from("detailed diff content")); + return 0; + } + return 0; + }); + mockExec.getExecOutput.mockImplementation(async (cmd, args) => { + const joinedArgs = args.join(" "); + if (joinedArgs === "diff --name-only .github/workflows/*.lock.yml") { + return { stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "rev-parse HEAD") { + return { stdout: "base-head-sha\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "ls-remote origin refs/heads/aw/recompile-workflows") { + return { stdout: "remote-head-sha\trefs/heads/aw/recompile-workflows\n", stderr: "", exitCode: 0 }; + } + if (joinedArgs === "show refs/remotes/origin/aw/recompile-workflows:.github/workflows/example.lock.yml") { + return { stdout: "name: example\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 42, + html_url: "https://github.com/testowner/testrepo/issues/42", + }, + ], + }, + }); + mockGithub.rest.pulls.list.mockResolvedValue({ + data: [ + { + number: 45, + html_url: "https://github.com/testowner/testrepo/pull/45", + }, + ], + }); + + const { main } = await import("./check_workflow_recompile_needed.cjs"); + await main(); + + expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + pull_number: 45, + }) + ); + expect(mockCore.info).toHaveBeenCalledWith("Existing maintenance branch already contains the latest compiled workflow lock files"); + }); + + it("should stay in issue mode without a configured maintenance token secret", async () => { + mockExec.exec + .mockImplementationOnce(async (cmd, args, options) => { + if (options?.listeners?.stdout) { + options.listeners.stdout(Buffer.from("diff content")); + } + return 1; + }) + .mockImplementationOnce(async (cmd, args, options) => { + if (options?.listeners?.stdout) { + options.listeners.stdout(Buffer.from("detailed diff content")); + } + return 0; + }); + mockExec.getExecOutput.mockResolvedValueOnce({ stdout: ".github/workflows/example.lock.yml\n", stderr: "", exitCode: 0 }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 0, + items: [], + }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { + number: 43, + html_url: "https://github.com/testowner/testrepo/issues/43", + }, + }); + + const { main } = await import("./check_workflow_recompile_needed.cjs"); + await main(); + + expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.create).toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith("Configured maintenance token present: false"); + }); }); diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 4f8edc3a874..2b89cef3011 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -207,11 +207,12 @@ async function resolveLocalHeadSha(cwd) { * @param {string} opts.cwd - Working directory of the local git checkout * @param {object} [opts.gitAuthEnv] - Environment variables for git push fallback auth * @param {boolean} [opts.signedCommits=true] - When false, skip GraphQL signed commits and use git push directly + * @param {boolean} [opts.allowGitPushFallback=true] - When false, refuse any fallback path that would use direct git push * @param {Record} [opts.resolvedTemporaryIds] - Resolved temporary IDs map * @param {string} [opts.currentRepo] - Repository slug used for same-repo temporary ID resolution * @returns {Promise} SHA of the commit that landed on the target branch */ -async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, resolvedTemporaryIds, currentRepo }) { +async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo }) { const effectiveCurrentRepo = currentRepo || `${owner}/${repo}`; const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds, { defaultRepo: effectiveCurrentRepo, @@ -234,6 +235,9 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // The GraphQL createCommitOnBranch path cannot handle root commits (no parent to resolve), // so skip it entirely and fall directly through to git push. if (!baseRef) { + if (allowGitPushFallback === false) { + throw new Error(`pushSignedCommits: cannot push branch '${branch}' without a baseRef when git push fallback is disabled. ` + `Seed the branch with a signed commit first, then retry.`); + } core.info(`pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch ${branch}`); try { const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); @@ -500,6 +504,9 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c { cause: err } ); } + if (allowGitPushFallback === false) { + throw new Error(`pushSignedCommits: signed commit push failed for branch '${branch}' and git push fallback is disabled: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); + } core.warning(`pushSignedCommits: GraphQL signed push failed, falling back to git push: ${err instanceof Error ? err.message : String(err)}`); const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); core.info(`pushSignedCommits: git push fallback completed, using pushed SHA ${fallbackSha}`); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 46ea8b98952..2367e6cccb2 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -821,6 +821,39 @@ describe("push_signed_commits integration tests", () => { const localOid = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); expect(remoteOid).toBe(localOid); }); + + it("should refuse git push fallback when explicitly disabled", async () => { + execGit(["checkout", "-b", "no-fallback-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "base.txt"), "Base content\n"); + execGit(["add", "base.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Base commit"], { cwd: workDir }); + execGit(["push", "-u", "origin", "no-fallback-branch"], { cwd: workDir }); + + const remoteOidBefore = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + + fs.writeFileSync(path.join(workDir, "extra.txt"), "Extra content\n"); + execGit(["add", "extra.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Extra commit"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient({ failWithError: new Error("GraphQL: not supported on GHES") }); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "no-fallback-branch", + baseRef: "origin/no-fallback-branch", + cwd: workDir, + allowGitPushFallback: false, + }) + ).rejects.toThrow("git push fallback is disabled"); + + const remoteOidAfter = execGit(["rev-parse", "refs/heads/no-fallback-branch"], { cwd: bareDir }).stdout.trim(); + expect(remoteOidAfter).toBe(remoteOidBefore); + expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("falling back to git push")); + }); }); describe("git auth environment propagation", () => { diff --git a/pkg/actionpins/data/action_pins.json b/pkg/actionpins/data/action_pins.json index 334d0b7954e..828732d7e61 100644 --- a/pkg/actionpins/data/action_pins.json +++ b/pkg/actionpins/data/action_pins.json @@ -138,6 +138,11 @@ "version": "v4.1.0", "sha": "4907a6ddec9925e35a0a9e82d7399ccc52663121" }, + "docker/metadata-action@v6": { + "repo": "docker/metadata-action", + "version": "v6", + "sha": "80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9" + }, "docker/metadata-action@v6.0.0": { "repo": "docker/metadata-action", "version": "v6.0.0", diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index dd993759e1c..62669a634b6 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -59,7 +59,21 @@ "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" - } + }, + "compile": { + "description": "Configuration for the compile-workflows maintenance job.", + "type": "object", + "additionalProperties": false, + "properties": { + "create_pull_request_github_token": { + "description": "GitHub Actions secret name used by the compile-workflows job for GitHub API calls and branch pushes when compile drift should create or update a deduplicated pull request instead of opening an issue.", + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "examples": ["GH_AW_GITHUB_TOKEN", "MAINTENANCE_GITHUB_TOKEN"] + } + } + } } } ] diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index 334d0b7954e..828732d7e61 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -138,6 +138,11 @@ "version": "v4.1.0", "sha": "4907a6ddec9925e35a0a9e82d7399ccc52663121" }, + "docker/metadata-action@v6": { + "repo": "docker/metadata-action", + "version": "v6", + "sha": "80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9" + }, "docker/metadata-action@v6.0.0": { "repo": "docker/metadata-action", "version": "v6.0.0", diff --git a/pkg/workflow/github_token.go b/pkg/workflow/github_token.go index 52a558c1ce2..be3ca3e8a53 100644 --- a/pkg/workflow/github_token.go +++ b/pkg/workflow/github_token.go @@ -46,6 +46,22 @@ func getEffectiveSafeOutputGitHubToken(customToken string) string { return "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" } +// getEffectiveMaintenanceGitHubToken returns the configured GitHub token secret +// expression to use for maintenance compile-workflows operations. +// +// No fallback chain is applied here. Maintenance compile PR mode must use the +// explicitly configured secret so the generated workflow does not silently fall +// back to a token without permission to write workflow files. +func getEffectiveMaintenanceGitHubToken(secretName string) string { + secretName = strings.TrimSpace(secretName) + if secretName == "" { + tokenLog.Print("No maintenance compile GitHub token secret configured") + return "" + } + tokenLog.Printf("Using configured maintenance compile GitHub token secret %q", secretName) + return wrapGitHubExpression(fmt.Sprintf("secrets.%s", secretName)) +} + // getEffectiveCopilotRequestsToken returns the GitHub token to use for Copilot-related operations, // with precedence: // 1. Custom token passed as parameter (e.g., from safe-outputs config github-token field) diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index ae27ea57321..644ac7e955f 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -147,9 +147,15 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo const defaultRunsOn = "ubuntu-slim" var configuredRunsOn RunsOnValue disableLabelTrigger := true // default: disable label-triggered jobs (opt-in) + var compileGitHubTokenSecret string + enableCompileCreatePullRequest := false if repoConfig != nil && repoConfig.Maintenance != nil { configuredRunsOn = repoConfig.Maintenance.RunsOn disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() + if repoConfig.Maintenance.Compile != nil { + compileGitHubTokenSecret = repoConfig.Maintenance.Compile.CreatePullRequestGitHubToken + enableCompileCreatePullRequest = strings.TrimSpace(compileGitHubTokenSecret) != "" + } } runsOnValue := FormatRunsOn(configuredRunsOn, defaultRunsOn) @@ -214,6 +220,11 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo defaultBranch := FetchDefaultBranch(repoSlug) // Generate the YAML content for the maintenance workflow + maintenanceLog.Printf( + "Maintenance compile configuration: createPullRequest=%v tokenSecretConfigured=%v", + enableCompileCreatePullRequest, + strings.TrimSpace(compileGitHubTokenSecret) != "", + ) content := buildMaintenanceWorkflowYAML(ctx, buildMaintenanceWorkflowYAMLOptions{ cronSchedule: cronSchedule, scheduleDesc: scheduleDesc, @@ -226,6 +237,8 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo configuredRunsOn: configuredRunsOn, defaultBranch: defaultBranch, disableLabelTrigger: disableLabelTrigger, + compileGitHubToken: getEffectiveMaintenanceGitHubToken(compileGitHubTokenSecret), + createCompilePR: enableCompileCreatePullRequest, }) // Write the maintenance workflow file diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 372a660f3eb..cc238dad5e6 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -1110,6 +1110,65 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { if strings.Contains(yaml, "compile --validate --validate-images --verbose") { t.Errorf("Workflow should not require --validate-images in compile-workflows, but generated YAML includes it:\n%s", yaml) } + if strings.Contains(yaml, " env:\n with:\n") { + t.Errorf("Workflow should not emit an empty env block in compile-workflows, but generated YAML includes one:\n%s", yaml) + } + }) + + t.Run("compile-workflows can create pull requests with custom token secret", func(t *testing.T) { + const compileJobSectionSearchRange = 500 + tmpDir := t.TempDir() + repoConfig := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + Compile: &MaintenanceCompileConfig{ + CreatePullRequestGitHubToken: "MAINTENANCE_TOKEN", + }, + }, + } + err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ + WorkflowDataList: workflowDataList, + WorkflowDir: tmpDir, + Version: "v1.0.0", + ActionMode: ActionModeDev, + ActionTag: "", + RepoConfig: repoConfig, + RepoSlug: "", + }) + 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) + + compileIdx := strings.Index(yaml, "\n compile-workflows:") + if compileIdx == -1 { + t.Fatal("Job compile-workflows not found in generated workflow") + } + jobSection := yaml[compileIdx : compileIdx+compileJobSectionSearchRange] + if !strings.Contains(jobSection, "contents: read") { + t.Errorf("compile-workflows should keep contents: read permission, got:\n%s", jobSection) + } + if !strings.Contains(jobSection, "issues: write") { + t.Errorf("compile-workflows should keep issues: write permission, got:\n%s", jobSection) + } + if strings.Contains(jobSection, "pull-requests: write") { + t.Errorf("compile-workflows should not request pull-requests: write in PR mode, got:\n%s", jobSection) + } + if strings.Contains(jobSection, "contents: write") { + t.Errorf("compile-workflows should not request contents: write in PR mode, got:\n%s", jobSection) + } + if !strings.Contains(yaml, "GH_AW_MAINTENANCE_GITHUB_TOKEN: ${{ secrets.MAINTENANCE_TOKEN }}") { + t.Errorf("workflow should use configured maintenance github token secret, got:\n%s", yaml) + } + if !strings.Contains(yaml, "github-token: ${{ env.GH_AW_MAINTENANCE_GITHUB_TOKEN }}") { + t.Errorf("workflow should pass maintenance token to github-script, got:\n%s", yaml) + } + if strings.Contains(yaml, "GH_AW_WORKFLOW_RECOMPILE_CREATE_PULL_REQUEST") { + t.Errorf("workflow should not emit a separate PR mode env var, got:\n%s", yaml) + } }) } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index e1ec930e61a..a1898079c0c 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -23,6 +23,8 @@ type buildMaintenanceWorkflowYAMLOptions struct { configuredRunsOn RunsOnValue defaultBranch string disableLabelTrigger bool + compileGitHubToken string + createCompilePR bool } // buildMaintenanceWorkflowYAML generates the complete YAML content for the @@ -43,7 +45,9 @@ func buildMaintenanceWorkflowYAML( configuredRunsOn := opts.configuredRunsOn defaultBranch := opts.defaultBranch disableLabelTrigger := opts.disableLabelTrigger - maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q disableLabelTrigger=%v", actionMode, minExpiresDays, cronSchedule, defaultBranch, disableLabelTrigger) + compileGitHubToken := opts.compileGitHubToken + createCompilePR := opts.createCompilePR + maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q disableLabelTrigger=%v createCompilePR=%v", actionMode, minExpiresDays, cronSchedule, defaultBranch, disableLabelTrigger, createCompilePR) var yaml strings.Builder @@ -868,10 +872,21 @@ jobs: with: destination: ${{ runner.temp }}/gh-aw/actions - - name: Check for out-of-sync workflows and create issue if needed + - name: Check for out-of-sync workflows and create issue or pull request if needed uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - script: | +`) + if compileGitHubToken != "" { + yaml.WriteString(` env: + GH_AW_MAINTENANCE_GITHUB_TOKEN: ` + compileGitHubToken + ` +`) + } + yaml.WriteString(` with: +`) + if compileGitHubToken != "" { + yaml.WriteString(` github-token: ${{ env.GH_AW_MAINTENANCE_GITHUB_TOKEN }} +`) + } + yaml.WriteString(` 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_workflow_recompile_needed.cjs'); diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 684f4d327c1..b1cbf424ccb 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -11,7 +11,10 @@ // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72, // expiration (hours) for conclusion failure issues -// "label_triggers": true // set to true to enable all label-triggered jobs (opt-in) +// "label_triggers": true, // set to true to enable all label-triggered jobs (opt-in) +// "compile": { +// "create_pull_request_github_token": "MY_REPO_TOKEN" // create/update a deduplicated PR instead of an issue +// } // } // maintenance jobs (default: ubuntu-slim) // } // @@ -26,12 +29,14 @@ import ( "fmt" "os" "path/filepath" + "regexp" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" ) var repoConfigLog = logger.New("workflow:repo_config") +var repoConfigSecretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // RepoConfigFileName is the path of the repository-level configuration file // relative to the git root. @@ -67,6 +72,14 @@ func (r *RunsOnValue) UnmarshalJSON(data []byte) error { } // MaintenanceConfig holds maintenance-workflow-specific settings from aw.json. +type MaintenanceCompileConfig struct { + // CreatePullRequestGitHubToken is the secret name used by the compile-workflows + // maintenance job for GitHub API calls and branch pushes. When configured, + // out-of-sync compiled workflows are reported via a deduplicated pull request + // instead of an issue. + CreatePullRequestGitHubToken string `json:"create_pull_request_github_token,omitempty"` +} + type MaintenanceConfig struct { // RunsOn is the runner label or labels used for all jobs in agentics-maintenance.yml. RunsOn RunsOnValue `json:"runs_on,omitempty"` @@ -81,6 +94,9 @@ type MaintenanceConfig struct { // 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"` + + // Compile controls compile-workflows maintenance job behavior. + Compile *MaintenanceCompileConfig `json:"compile,omitempty"` } // IsLabelTriggerEnabled returns true only when label_triggers is explicitly set to true. @@ -175,6 +191,9 @@ func LoadRepoConfig(gitRoot string) (*RepoConfig, error) { if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("failed to parse %s: %w", RepoConfigFileName, err) } + if err := validateRepoConfigValues(&cfg); err != nil { + return nil, err + } return &cfg, nil } @@ -201,6 +220,18 @@ func validateRepoConfigJSON(data []byte, filePath string) error { return nil } +func validateRepoConfigValues(cfg *RepoConfig) error { + if cfg == nil || cfg.Maintenance == nil || cfg.Maintenance.Compile == nil { + return nil + } + compileCfg := cfg.Maintenance.Compile + secretName := compileCfg.CreatePullRequestGitHubToken + if secretName != "" && !repoConfigSecretNamePattern.MatchString(secretName) { + return fmt.Errorf("invalid %s: maintenance.compile.create_pull_request_github_token must match %s", RepoConfigFileName, repoConfigSecretNamePattern.String()) + } + return nil +} + // FormatRunsOn serialises a RunsOnValue to a YAML-compatible string that can // be inlined directly after "runs-on: " in a generated workflow. // diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index db00a5f3c5a..20ea2091e0e 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -83,6 +83,17 @@ func TestLoadRepoConfig_ActionFailureIssueExpires(t *testing.T) { assert.Equal(t, 72, cfg.ActionFailureIssueExpiresHours(), "accessor should return configured expiration") } +func TestLoadRepoConfig_MaintenanceCompileConfig(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"compile": {"create_pull_request_github_token": "MAINTENANCE_TOKEN"}}}`) + + 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.Compile, "compile config should be set") + assert.Equal(t, "MAINTENANCE_TOKEN", cfg.Maintenance.Compile.CreatePullRequestGitHubToken) +} + func TestLoadRepoConfig_InvalidJSON(t *testing.T) { dir := t.TempDir() writeAWJSONRaw(t, dir, `not-json`) @@ -152,6 +163,14 @@ func TestLoadRepoConfig_InvalidActionFailureIssueExpires(t *testing.T) { assert.Error(t, err, "action_failure_issue_expires must be >= 1") } +func TestLoadRepoConfig_InvalidMaintenanceCompileGitHubTokenSecret(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"compile": {"create_pull_request_github_token": "bad-secret"}}}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "create_pull_request_github_token must be a valid secret name") +} + func TestLoadRepoConfig_GHESTrue(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"ghes": true}`)