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}`)