diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs
index f838019643..1c1bbc9275 100644
--- a/actions/setup/js/create_pull_request.cjs
+++ b/actions/setup/js/create_pull_request.cjs
@@ -18,6 +18,7 @@ const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { generateFooterWithMessages } = require("./messages_footer.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
+const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
@@ -839,6 +840,17 @@ ${patchPreview}`;
)
.write();
+ // Push an extra empty commit if a token is configured.
+ // This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events.
+ const ciTriggerResult = await pushExtraEmptyCommit({
+ branchName,
+ repoOwner: repoParts.owner,
+ repoName: repoParts.repo,
+ });
+ if (ciTriggerResult.success && !ciTriggerResult.skipped) {
+ core.info("Extra empty commit pushed - CI checks should start shortly");
+ }
+
// Return success with PR details
return {
success: true,
diff --git a/actions/setup/js/extra_empty_commit.cjs b/actions/setup/js/extra_empty_commit.cjs
new file mode 100644
index 0000000000..98dffcf30b
--- /dev/null
+++ b/actions/setup/js/extra_empty_commit.cjs
@@ -0,0 +1,119 @@
+// @ts-check
+///
+
+/**
+ * @fileoverview Extra Empty Commit Helper
+ *
+ * Pushes an empty commit to a branch using a different token to trigger CI events.
+ * This works around the GitHub Actions limitation where events created with
+ * GITHUB_TOKEN do not trigger other workflow runs.
+ *
+ * The token comes from `github-token-for-extra-empty-commit` in safe-outputs config
+ * (passed as GH_AW_EXTRA_EMPTY_COMMIT_TOKEN env var), or `app` for GitHub App authentication.
+ */
+
+/**
+ * Push an empty commit to a branch using a dedicated token.
+ * This commit is pushed with different authentication so that push/PR events
+ * are triggered for CI checks to run.
+ *
+ * @param {Object} options - Options for the extra empty commit
+ * @param {string} options.branchName - The branch to push the empty commit to
+ * @param {string} options.repoOwner - Repository owner
+ * @param {string} options.repoName - Repository name
+ * @param {string} [options.commitMessage] - Custom commit message (default: "ci: trigger CI checks")
+ * @returns {Promise<{success: boolean, skipped?: boolean, error?: string}>}
+ */
+async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage }) {
+ const token = process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN;
+
+ if (!token || !token.trim()) {
+ core.info("No extra empty commit token configured - skipping");
+ return { success: true, skipped: true };
+ }
+
+ core.info("Extra empty commit token detected - pushing empty commit to trigger CI events");
+
+ try {
+ // Cycle prevention: count empty commits in the last 60 commits on this branch.
+ // If 30 or more are empty, skip pushing to avoid infinite trigger loops.
+ const MAX_EMPTY_COMMITS = 30;
+ const COMMITS_TO_CHECK = 60;
+ let emptyCommitCount = 0;
+
+ try {
+ let logOutput = "";
+ // List last N commits: for each, output "COMMIT:" then changed file names.
+ // Empty commits will have no files listed after the hash line.
+ await exec.exec("git", ["log", `--max-count=${COMMITS_TO_CHECK}`, "--format=COMMIT:%H", "--name-only", "HEAD"], {
+ listeners: {
+ stdout: data => {
+ logOutput += data.toString();
+ },
+ },
+ silent: true,
+ });
+ // Split by COMMIT: markers; each chunk starts with the hash, followed by filenames
+ const chunks = logOutput.split("COMMIT:").filter(c => c.trim());
+ for (const chunk of chunks) {
+ const lines = chunk.split("\n").filter(l => l.trim());
+ // First line is the hash, remaining lines are changed files
+ if (lines.length <= 1) {
+ emptyCommitCount++;
+ }
+ }
+ } catch {
+ // If we can't check, default to allowing the push
+ emptyCommitCount = 0;
+ }
+
+ if (emptyCommitCount >= MAX_EMPTY_COMMITS) {
+ core.warning(`Cycle prevention: found ${emptyCommitCount} empty commits in the last ${COMMITS_TO_CHECK} commits on ${branchName}. ` + `Skipping extra empty commit to avoid potential infinite loop.`);
+ return { success: true, skipped: true };
+ }
+
+ core.info(`Cycle check passed: ${emptyCommitCount} empty commit(s) in last ${COMMITS_TO_CHECK} (limit: ${MAX_EMPTY_COMMITS})`);
+
+ // Configure git remote with the token for authentication
+ const remoteUrl = `https://x-access-token:${token}@github.com/${repoOwner}/${repoName}.git`;
+
+ // Add a temporary remote with the token
+ try {
+ await exec.exec("git", ["remote", "remove", "ci-trigger"]);
+ } catch {
+ // Remote doesn't exist yet, that's fine
+ }
+ await exec.exec("git", ["remote", "add", "ci-trigger", remoteUrl]);
+
+ // Create and push an empty commit
+ const message = commitMessage || "ci: trigger CI checks";
+ await exec.exec("git", ["commit", "--allow-empty", "-m", message]);
+ await exec.exec("git", ["push", "ci-trigger", branchName]);
+
+ core.info(`Extra empty commit pushed to ${branchName} successfully`);
+
+ // Clean up the temporary remote
+ try {
+ await exec.exec("git", ["remote", "remove", "ci-trigger"]);
+ } catch {
+ // Non-fatal cleanup error
+ }
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ core.warning(`Failed to push extra empty commit: ${errorMessage}`);
+
+ // Clean up the temporary remote on failure
+ try {
+ await exec.exec("git", ["remote", "remove", "ci-trigger"]);
+ } catch {
+ // Non-fatal cleanup error
+ }
+
+ // Extra empty commit failure is not fatal - the main push already succeeded
+ return { success: false, error: errorMessage };
+ }
+}
+
+module.exports = { pushExtraEmptyCommit };
diff --git a/actions/setup/js/extra_empty_commit.test.cjs b/actions/setup/js/extra_empty_commit.test.cjs
new file mode 100644
index 0000000000..3ba863a64b
--- /dev/null
+++ b/actions/setup/js/extra_empty_commit.test.cjs
@@ -0,0 +1,390 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+describe("extra_empty_commit.cjs", () => {
+ let mockCore;
+ let mockExec;
+ let pushExtraEmptyCommit;
+ let originalEnv;
+
+ beforeEach(() => {
+ originalEnv = process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN;
+
+ mockCore = {
+ info: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ };
+
+ // Default exec mock: resolves successfully, no stdout output
+ mockExec = {
+ exec: vi.fn().mockResolvedValue(0),
+ };
+
+ global.core = mockCore;
+ global.exec = mockExec;
+
+ // Clear module cache so env changes take effect
+ delete require.cache[require.resolve("./extra_empty_commit.cjs")];
+ });
+
+ afterEach(() => {
+ if (originalEnv !== undefined) {
+ process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = originalEnv;
+ } else {
+ delete process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN;
+ }
+ delete global.core;
+ delete global.exec;
+ vi.clearAllMocks();
+ });
+
+ /**
+ * Helper to configure the exec mock so that `git log` calls invoke the
+ * stdout listener with the supplied output string, while all other
+ * exec calls resolve normally.
+ */
+ function mockGitLogOutput(logOutput) {
+ mockExec.exec.mockImplementation(async (cmd, args, options) => {
+ if (cmd === "git" && args && args[0] === "log" && options && options.listeners && options.listeners.stdout) {
+ options.listeners.stdout(Buffer.from(logOutput));
+ }
+ return 0;
+ });
+ }
+
+ // ──────────────────────────────────────────────────────
+ // Token presence
+ // ──────────────────────────────────────────────────────
+
+ describe("when no extra empty commit token is set", () => {
+ it("should skip and return success with skipped=true", async () => {
+ delete process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN;
+ ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true, skipped: true });
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No extra empty commit token"));
+ expect(mockExec.exec).not.toHaveBeenCalled();
+ });
+
+ it("should skip when token is empty string", async () => {
+ process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "";
+ ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true, skipped: true });
+ });
+
+ it("should skip when token is whitespace only", async () => {
+ process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = " ";
+ ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true, skipped: true });
+ });
+ });
+
+ // ──────────────────────────────────────────────────────
+ // Successful push (no cycle issues)
+ // ──────────────────────────────────────────────────────
+
+ describe("when token is set and no cycle issues", () => {
+ beforeEach(() => {
+ process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "ghp_test_token_123";
+ // Simulate git log showing 5 commits, all with file changes (non-empty)
+ const logOutput = ["COMMIT:aaa111", "file1.txt", "", "COMMIT:bbb222", "file2.txt", "file3.txt", "", "COMMIT:ccc333", "file4.txt", "", "COMMIT:ddd444", "file5.txt", "", "COMMIT:eee555", "file6.txt", ""].join("\n");
+ mockGitLogOutput(logOutput);
+ ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
+ });
+
+ it("should push an empty commit and return success", async () => {
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Extra empty commit token detected"));
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cycle check passed"));
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Extra empty commit pushed"));
+ });
+
+ it("should add and remove a ci-trigger remote", async () => {
+ await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ const execCalls = mockExec.exec.mock.calls;
+ // Find remote add call
+ const addRemote = execCalls.find(c => c[0] === "git" && c[1] && c[1][0] === "remote" && c[1][1] === "add");
+ expect(addRemote).toBeDefined();
+ expect(addRemote[1]).toEqual(["remote", "add", "ci-trigger", expect.stringContaining("x-access-token:ghp_test_token_123")]);
+
+ // Find remote remove cleanup call (after push)
+ const removeRemoteCalls = execCalls.filter(c => c[0] === "git" && c[1] && c[1][0] === "remote" && c[1][1] === "remove");
+ expect(removeRemoteCalls.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should use default commit message when none provided", async () => {
+ await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
+ expect(commitCall).toBeDefined();
+ expect(commitCall[1]).toEqual(["commit", "--allow-empty", "-m", "ci: trigger CI checks"]);
+ });
+
+ it("should use custom commit message when provided", async () => {
+ await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ commitMessage: "chore: custom CI trigger",
+ });
+
+ const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
+ expect(commitCall[1]).toEqual(["commit", "--allow-empty", "-m", "chore: custom CI trigger"]);
+ });
+
+ it("should push to the correct branch", async () => {
+ await pushExtraEmptyCommit({
+ branchName: "my-feature",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ const pushCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "push");
+ expect(pushCall).toBeDefined();
+ expect(pushCall[1]).toEqual(["push", "ci-trigger", "my-feature"]);
+ });
+ });
+
+ // ──────────────────────────────────────────────────────
+ // Cycle prevention
+ // ──────────────────────────────────────────────────────
+
+ describe("cycle prevention", () => {
+ beforeEach(() => {
+ process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "ghp_test_token_123";
+ ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
+ });
+
+ it("should skip when 30 or more empty commits found in last 60", async () => {
+ // Build git log output with 30 empty commits (hash only, no files)
+ const commits = [];
+ for (let i = 0; i < 30; i++) {
+ commits.push(`COMMIT:empty${i.toString().padStart(3, "0")}`);
+ commits.push(""); // blank line = no files
+ }
+ // Add some non-empty commits too
+ for (let i = 0; i < 10; i++) {
+ commits.push(`COMMIT:real${i.toString().padStart(3, "0")}`);
+ commits.push(`file${i}.txt`);
+ commits.push("");
+ }
+ mockGitLogOutput(commits.join("\n"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true, skipped: true });
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Cycle prevention"));
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("30 empty commits"));
+
+ // Should NOT have pushed (no commit or push calls after the log check)
+ const commitCalls = mockExec.exec.mock.calls.filter(c => c[0] === "git" && c[1] && c[1][0] === "commit");
+ expect(commitCalls).toHaveLength(0);
+ });
+
+ it("should allow push when fewer than 30 empty commits", async () => {
+ // 29 empty commits - just under the limit
+ const commits = [];
+ for (let i = 0; i < 29; i++) {
+ commits.push(`COMMIT:empty${i.toString().padStart(3, "0")}`);
+ commits.push("");
+ }
+ for (let i = 0; i < 5; i++) {
+ commits.push(`COMMIT:real${i.toString().padStart(3, "0")}`);
+ commits.push(`file${i}.txt`);
+ commits.push("");
+ }
+ mockGitLogOutput(commits.join("\n"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(mockCore.warning).not.toHaveBeenCalled();
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cycle check passed: 29 empty commit"));
+ });
+
+ it("should allow push when no commits exist (empty repo)", async () => {
+ mockGitLogOutput("");
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true });
+ });
+
+ it("should allow push when all commits have file changes", async () => {
+ const commits = [];
+ for (let i = 0; i < 50; i++) {
+ commits.push(`COMMIT:hash${i.toString().padStart(3, "0")}`);
+ commits.push(`src/file${i}.go`);
+ commits.push("");
+ }
+ mockGitLogOutput(commits.join("\n"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cycle check passed: 0 empty commit"));
+ });
+
+ it("should allow push if git log fails (defaults to 0 empty commits)", async () => {
+ // Make git log throw an error
+ mockExec.exec.mockImplementation(async (cmd, args, options) => {
+ if (cmd === "git" && args && args[0] === "log") {
+ throw new Error("git log failed");
+ }
+ return 0;
+ });
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true });
+ });
+
+ it("should skip at exactly 30 empty commits (boundary)", async () => {
+ const commits = [];
+ // Exactly 30 empty commits
+ for (let i = 0; i < 30; i++) {
+ commits.push(`COMMIT:empty${i.toString().padStart(3, "0")}`);
+ commits.push("");
+ }
+ // 30 non-empty commits to fill up to 60
+ for (let i = 0; i < 30; i++) {
+ commits.push(`COMMIT:real${i.toString().padStart(3, "0")}`);
+ commits.push(`file${i}.txt`);
+ commits.push("");
+ }
+ mockGitLogOutput(commits.join("\n"));
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result).toEqual({ success: true, skipped: true });
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Cycle prevention"));
+ });
+ });
+
+ // ──────────────────────────────────────────────────────
+ // Error handling
+ // ──────────────────────────────────────────────────────
+
+ describe("error handling", () => {
+ beforeEach(() => {
+ process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "ghp_test_token_123";
+ // No empty commits in log
+ mockGitLogOutput("COMMIT:abc123\nfile.txt\n");
+ ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
+ });
+
+ it("should return error result when push fails", async () => {
+ let callCount = 0;
+ mockExec.exec.mockImplementation(async (cmd, args, options) => {
+ // Let git log succeed with stdout listener
+ if (cmd === "git" && args && args[0] === "log" && options && options.listeners) {
+ options.listeners.stdout(Buffer.from("COMMIT:abc123\nfile.txt\n"));
+ return 0;
+ }
+ // Let remote operations and commit succeed, but fail on push
+ if (cmd === "git" && args && args[0] === "push") {
+ throw new Error("authentication failed");
+ }
+ return 0;
+ });
+
+ const result = await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("authentication failed");
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to push extra empty commit"));
+ });
+
+ it("should clean up remote even when push fails", async () => {
+ const remoteRemoveCalls = [];
+ mockExec.exec.mockImplementation(async (cmd, args, options) => {
+ if (cmd === "git" && args && args[0] === "log" && options && options.listeners) {
+ options.listeners.stdout(Buffer.from("COMMIT:abc123\nfile.txt\n"));
+ return 0;
+ }
+ if (cmd === "git" && args && args[0] === "remote" && args[1] === "remove") {
+ remoteRemoveCalls.push(args);
+ return 0;
+ }
+ if (cmd === "git" && args && args[0] === "push") {
+ throw new Error("push failed");
+ }
+ return 0;
+ });
+
+ await pushExtraEmptyCommit({
+ branchName: "feature-branch",
+ repoOwner: "test-owner",
+ repoName: "test-repo",
+ });
+
+ // Should have at least one remote remove call for cleanup
+ const ciTriggerRemoveCall = remoteRemoveCalls.find(args => args[2] === "ci-trigger");
+ expect(ciTriggerRemoveCall).toBeDefined();
+ });
+ });
+});
diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs
index 47d6fdeda7..58078fcc4e 100644
--- a/actions/setup/js/push_to_pull_request_branch.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.cjs
@@ -8,6 +8,7 @@ const { updateActivationCommentWithCommit } = require("./update_activation_comme
const { getErrorMessage } = require("./error_helpers.cjs");
const { replaceTemporaryIdReferences } = require("./temporary_id.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
+const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
@@ -402,6 +403,19 @@ async function main(config = {}) {
await core.summary.addRaw(summaryContent).write();
+ // Push an extra empty commit if a token is configured and changes were pushed.
+ // This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events.
+ if (hasChanges) {
+ const ciTriggerResult = await pushExtraEmptyCommit({
+ branchName,
+ repoOwner: context.repo.owner,
+ repoName: context.repo.repo,
+ });
+ if (ciTriggerResult.success && !ciTriggerResult.skipped) {
+ core.info("Extra empty commit pushed - CI checks should start shortly");
+ }
+ }
+
return {
success: true,
branch_name: branchName,
diff --git a/docs/src/content/docs/reference/auth.mdx b/docs/src/content/docs/reference/auth.mdx
index fc80de2f0b..78eaa961f7 100644
--- a/docs/src/content/docs/reference/auth.mdx
+++ b/docs/src/content/docs/reference/auth.mdx
@@ -26,6 +26,7 @@ You will need one of the following GitHub Actions secrets configured in your rep
Depending on what your workflow needs to do, you may need additional GitHub tokens added as repository secrets:
- **Lockdown mode, cross-repo access, or remote GitHub tools** – Add [`GH_AW_GITHUB_TOKEN`](#gh_aw_github_token)
+- **Trigger CI on PRs created by workflows** – Use [`github-token-for-extra-empty-commit`](/gh-aw/reference/safe-outputs/#triggering-ci-on-created-pull-requests)
- **GitHub Projects v2 operations** – Add [`GH_AW_PROJECT_GITHUB_TOKEN`](#gh_aw_project_github_token)
- **Assign Copilot coding agent to issues/PRs** – Add [`GH_AW_AGENT_TOKEN`](#gh_aw_agent_token)
- **MCP server with special permissions** – Add [`GH_AW_GITHUB_MCP_SERVER_TOKEN`](#gh_aw_github_mcp_server_token)
@@ -193,6 +194,36 @@ gh aw secrets set GH_AW_GITHUB_MCP_SERVER_TOKEN --value "YOUR_MCP_PAT"
---
+### Triggering CI on Created Pull Requests
+
+Pull requests and pushes made with `GITHUB_TOKEN` do not trigger `pull_request`, `push`, or `pull_request_target` events. This is a [GitHub Actions security feature](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow) to prevent recursive workflows.
+
+To trigger CI, add `github-token-for-extra-empty-commit` to your `create-pull-request` or `push-to-pull-request-branch` safe output configuration. This pushes an additional empty commit using the specified token, which triggers CI events.
+
+**Two ways to configure:**
+
+| Method | Configuration | Description |
+|--------|--------------|-------------|
+| **Explicit token** | `github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }}` | Use a specific PAT or token per safe output |
+| **GitHub App** | `github-token-for-extra-empty-commit: app` | Use the configured GitHub App installation token |
+
+**Example:**
+
+```yaml wrap
+safe-outputs:
+ create-pull-request:
+ github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }}
+```
+
+**Required permissions**: Create a [fine-grained PAT](https://github.com/settings/personal-access-tokens/new) with `Contents: Read & Write` scoped to the relevant repositories.
+
+> [!TIP]
+> Use a fine-grained PAT with `contents: write` permission scoped to your repository. This is the minimum permission needed to push commits.
+
+See [Triggering CI on Created Pull Requests](/gh-aw/reference/safe-outputs/#triggering-ci-on-created-pull-requests) for behavioral details.
+
+---
+
### `GH_AW_PROJECT_GITHUB_TOKEN`
If using GitHub Projects v2 operations, you need to configure this GitHub Actions secret with a Personal Access Token (PAT) that has the appropriate scopes and permissions for Projects access.
diff --git a/docs/src/content/docs/reference/faq.md b/docs/src/content/docs/reference/faq.md
index 1954f067dd..c71076aa2a 100644
--- a/docs/src/content/docs/reference/faq.md
+++ b/docs/src/content/docs/reference/faq.md
@@ -303,25 +303,30 @@ This is expected GitHub Actions security behavior. Pull requests created using t
GitHub Actions prevents the `GITHUB_TOKEN` from triggering new workflow runs to avoid infinite loops and uncontrolled automation chains. Without this protection, a workflow could create a PR, which triggers another workflow, which creates another PR, and so on indefinitely.
-If you need CI checks to run on PRs created by agentic workflows, you have three options:
+If you need CI checks to run on PRs created by agentic workflows, you have several options:
-**Option 1: Use different authorization**
+**Option 1: Use `github-token-for-extra-empty-commit` (Recommended)**
-Configure your [`create-pull-request` safe output](/gh-aw/reference/safe-outputs/#pull-request-creation-create-pull-request) to use a PAT or a GitHub App. This allows PR creation to trigger CI workflows.
+Add a `github-token-for-extra-empty-commit` to your `create-pull-request` or `push-to-pull-request-branch` safe output. This pushes an empty commit using a different token after PR creation/push, which triggers CI events without changing the overall PR authorization.
-**Option 2: Use workflow_run trigger**
+```yaml wrap
+safe-outputs:
+ create-pull-request:
+ github-token-for-extra-empty-commit: ${{ secrets.CI_TRIGGER_PAT }}
+```
+
+See [Triggering CI on Created Pull Requests](/gh-aw/reference/safe-outputs/#triggering-ci-on-created-pull-requests) for details.
-Configure your CI workflows to run on `workflow_run` events, which allows them to react to completed workflows:
+**Option 2: Use different authorization for the entire safe output**
+
+Configure your [`create-pull-request` safe output](/gh-aw/reference/safe-outputs/#pull-request-creation-create-pull-request) to use a PAT or a GitHub App for all operations. This allows PR creation to trigger CI workflows, but changes the authorization for the entire PR creation process. The user or app associated with the token will be the author of the PR.
```yaml wrap
-on:
- workflow_run:
- workflows: ["Create Pull Request Workflow"]
- types: [completed]
+safe-outputs:
+ create-pull-request:
+ github-token: ${{ secrets.CI_USER_PAT }}
```
-This approach maintains security while allowing CI to run after PR creation. See [GitHub Actions workflow_run documentation](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) for details.
-
## Workflow Design
### Should I focus on one workflow, or write many different ones?
diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md
index 004620662b..b4ee5184d4 100644
--- a/docs/src/content/docs/reference/safe-outputs.md
+++ b/docs/src/content/docs/reference/safe-outputs.md
@@ -687,6 +687,7 @@ safe-outputs:
base-branch: "vnext" # target branch for PR (default: github.base_ref || github.ref_name)
fallback-as-issue: false # disable issue fallback (default: true)
github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions
+ github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # optional token to push empty commit triggering CI
```
The `base-branch` field specifies which branch the pull request should target. This is particularly useful for cross-repository PRs where you need to target non-default branches (e.g., `vnext`, `release/v1.0`, `staging`). When not specified, defaults to `github.base_ref` (the PR's target branch) with a fallback to `github.ref_name` (the workflow's branch) for push events.
@@ -707,6 +708,20 @@ safe-outputs:
When `create-pull-request` is configured, git commands (`checkout`, `branch`, `switch`, `add`, `rm`, `commit`, `merge`) are automatically enabled.
+#### Triggering CI on Created Pull Requests
+
+By default, pull requests created using `GITHUB_TOKEN` **do not trigger CI workflow runs** (this is a [GitHub Actions security feature](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow) to prevent event cascades). To trigger CI checks on PRs created by agentic workflows, configure a CI trigger token:
+
+```yaml wrap
+safe-outputs:
+ create-pull-request:
+ github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # PAT or token to trigger CI
+```
+
+When configured, an empty commit is pushed to the PR branch using the specified token after PR creation. Since this push comes from a different authentication context, it triggers `push` and `pull_request` events normally.
+
+Use a secret expression (e.g. `${{ secrets.CI_TOKEN }}`) or `app` for GitHub App auth. See the [Authentication reference](/gh-aw/reference/auth/) for token setup and required permissions.
+
### Close Pull Request (`close-pull-request:`)
Closes PRs without merging with optional comment. Filter by labels and title prefix. Target: `"triggering"` (PR event), `"*"` (any), or number.
@@ -833,10 +848,13 @@ safe-outputs:
max: 3 # max pushes per run (default: 1)
if-no-changes: "warn" # "warn" (default), "error", or "ignore"
github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions
+ github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # optional token to push empty commit triggering CI
```
When `push-to-pull-request-branch` is configured, git commands (`checkout`, `branch`, `switch`, `add`, `rm`, `commit`, `merge`) are automatically enabled.
+Like `create-pull-request`, pushes made with `GITHUB_TOKEN` do not trigger CI events. Use `github-token-for-extra-empty-commit` to trigger CI after pushing. See [Triggering CI on Created Pull Requests](#triggering-ci-on-created-pull-requests) for details.
+
#### Fail-Fast on Code Push Failure
If `push-to-pull-request-branch` (or `create-pull-request`) fails, the safe-output pipeline cancels all remaining non-code-push outputs. Each cancelled output is marked with an explicit reason such as "Cancelled: code push operation failed". The failure details appear in the agent failure issue or comment generated by the conclusion job.
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index cecb12b4ce..4bc94099a5 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -5119,6 +5119,10 @@
"type": "boolean",
"description": "Controls the fallback behavior when pull request creation fails. When true (default), an issue is created as a fallback with the patch content. When false, no issue is created and the workflow fails with an error. Setting to false also removes the issues:write permission requirement.",
"default": true
+ },
+ "github-token-for-extra-empty-commit": {
+ "type": "string",
+ "description": "Token used to push an empty commit after PR creation to trigger CI events. Works around the GITHUB_TOKEN limitation where pushes don't trigger workflow runs. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') or 'app' for GitHub App auth."
}
},
"additionalProperties": false,
@@ -6096,6 +6100,10 @@
"type": "boolean",
"description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)",
"examples": [true, false]
+ },
+ "github-token-for-extra-empty-commit": {
+ "type": "string",
+ "description": "Token used to push an empty commit after pushing changes to trigger CI events. Works around the GITHUB_TOKEN limitation where pushes don't trigger workflow runs. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') or 'app' for GitHub App auth."
}
},
"additionalProperties": false
diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go
index 58800ac858..6d35201fc5 100644
--- a/pkg/workflow/compiler_safe_outputs_job.go
+++ b/pkg/workflow/compiler_safe_outputs_job.go
@@ -405,6 +405,26 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID
}
}
+ // Add extra empty commit token if configured on create-pull-request or push-to-pull-request-branch.
+ // This token is used to push an empty commit after code changes to trigger CI events,
+ // working around the GITHUB_TOKEN limitation where events don't trigger other workflows.
+ if data.SafeOutputs != nil {
+ var extraEmptyCommitToken string
+ if data.SafeOutputs.CreatePullRequests != nil && data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit != "" {
+ extraEmptyCommitToken = data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit
+ } else if data.SafeOutputs.PushToPullRequestBranch != nil && data.SafeOutputs.PushToPullRequestBranch.GithubTokenForExtraEmptyCommit != "" {
+ extraEmptyCommitToken = data.SafeOutputs.PushToPullRequestBranch.GithubTokenForExtraEmptyCommit
+ }
+
+ if extraEmptyCommitToken == "app" {
+ envVars["GH_AW_EXTRA_EMPTY_COMMIT_TOKEN"] = "${{ steps.safe-outputs-app-token.outputs.token || '' }}"
+ consolidatedSafeOutputsJobLog.Print("Extra empty commit using GitHub App token")
+ } else if extraEmptyCommitToken != "" {
+ envVars["GH_AW_EXTRA_EMPTY_COMMIT_TOKEN"] = extraEmptyCommitToken
+ consolidatedSafeOutputsJobLog.Print("Extra empty commit using explicit token")
+ }
+ }
+
// Note: Asset upload configuration is not needed here because upload_assets
// is now handled as a separate job (see buildUploadAssetsJob)
diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go
index bc4e4df6a5..f958c16ee6 100644
--- a/pkg/workflow/create_pull_request.go
+++ b/pkg/workflow/create_pull_request.go
@@ -20,21 +20,22 @@ func getFallbackAsIssue(config *CreatePullRequestsConfig) bool {
// CreatePullRequestsConfig holds configuration for creating GitHub pull requests from agent output
type CreatePullRequestsConfig struct {
- BaseSafeOutputConfig `yaml:",inline"`
- TitlePrefix string `yaml:"title-prefix,omitempty"`
- Labels []string `yaml:"labels,omitempty"`
- AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones).
- Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request
- Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values
- IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore"
- AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches)
- TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests
- AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo)
- Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs)
- AutoMerge *string `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request when all required checks pass
- BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified)
- Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
- FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested.
+ BaseSafeOutputConfig `yaml:",inline"`
+ TitlePrefix string `yaml:"title-prefix,omitempty"`
+ Labels []string `yaml:"labels,omitempty"`
+ AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones).
+ Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request
+ Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values
+ IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore"
+ AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches)
+ TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests
+ AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo)
+ Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs)
+ AutoMerge *string `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request when all required checks pass
+ BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified)
+ Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
+ FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested.
+ GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth.
}
// buildCreateOutputPullRequestJob creates the create_pull_request job
@@ -164,6 +165,18 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa
createPRLog.Print("Footer disabled - XML markers will be included but visible footer content will be omitted")
}
+ // Add extra empty commit token if configured (for pushing an empty commit to trigger CI)
+ extraEmptyCommitToken := data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit
+ if extraEmptyCommitToken != "" {
+ if extraEmptyCommitToken == "app" {
+ customEnvVars = append(customEnvVars, " GH_AW_EXTRA_EMPTY_COMMIT_TOKEN: ${{ steps.safe-outputs-app-token.outputs.token || '' }}\n")
+ createPRLog.Print("Extra empty commit using GitHub App token")
+ } else {
+ customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_EXTRA_EMPTY_COMMIT_TOKEN: %s\n", extraEmptyCommitToken))
+ createPRLog.Printf("Extra empty commit using explicit token")
+ }
+ }
+
// Add standard environment variables (metadata + staged/target repo)
customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, data.SafeOutputs.CreatePullRequests.TargetRepoSlug)...)
diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go
index 67f7acc097..0a0b9f99ad 100644
--- a/pkg/workflow/push_to_pull_request_branch.go
+++ b/pkg/workflow/push_to_pull_request_branch.go
@@ -11,12 +11,13 @@ var pushToPullRequestBranchLog = logger.New("workflow:push_to_pull_request_branc
// PushToPullRequestBranchConfig holds configuration for pushing changes to a specific branch from agent output
type PushToPullRequestBranchConfig struct {
- BaseSafeOutputConfig `yaml:",inline"`
- Target string `yaml:"target,omitempty"` // Target for push-to-pull-request-branch: like add-comment but for pull requests
- TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for pull request validation
- Labels []string `yaml:"labels,omitempty"` // Required labels for pull request validation
- IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn", "error", or "ignore" (default: "warn")
- CommitTitleSuffix string `yaml:"commit-title-suffix,omitempty"` // Optional suffix to append to generated commit titles
+ BaseSafeOutputConfig `yaml:",inline"`
+ Target string `yaml:"target,omitempty"` // Target for push-to-pull-request-branch: like add-comment but for pull requests
+ TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for pull request validation
+ Labels []string `yaml:"labels,omitempty"` // Required labels for pull request validation
+ IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn", "error", or "ignore" (default: "warn")
+ CommitTitleSuffix string `yaml:"commit-title-suffix,omitempty"` // Optional suffix to append to generated commit titles
+ GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth.
}
// buildCheckoutRepository generates a checkout step with optional target repository and custom token
@@ -117,6 +118,14 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any)
}
}
+ // Parse github-token-for-extra-empty-commit (optional) - token for pushing empty commit to trigger CI
+ if emptyCommitToken, exists := configMap["github-token-for-extra-empty-commit"]; exists {
+ if emptyCommitTokenStr, ok := emptyCommitToken.(string); ok {
+ pushToBranchConfig.GithubTokenForExtraEmptyCommit = emptyCommitTokenStr
+ pushToPullRequestBranchLog.Printf("Extra empty commit token configured")
+ }
+ }
+
// Parse common base fields with default max of 0 (no limit)
c.parseBaseSafeOutputConfig(configMap, &pushToBranchConfig.BaseSafeOutputConfig, 0)
}