Skip to content
5 changes: 5 additions & 0 deletions actions/setup/js/checkout_pr_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ async function main() {
// Log detailed context for debugging
const { isFork } = logPRContext(eventName, pullRequest);

// Export fork status as environment variable for downstream jobs/steps
// This allows safe output collectors to reject fork PRs early
core.exportVariable("GH_AW_IS_FORK_PR", isFork ? "true" : "false");
core.info(`Exported GH_AW_IS_FORK_PR=${isFork ? "true" : "false"}`);

if (eventName === "pull_request") {
// For pull_request events, we run in the merge commit context
// The PR branch is already available, so we can use direct git commands
Expand Down
24 changes: 24 additions & 0 deletions actions/setup/js/checkout_pr_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("checkout_pr_branch.cjs", () => {
setOutput: vi.fn(),
startGroup: vi.fn(),
endGroup: vi.fn(),
exportVariable: vi.fn(),
summary: {
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -490,6 +491,29 @@ If the pull request is still open, verify that:
expect(mockCore.warning).toHaveBeenCalledWith("⚠️ Fork PR detected - gh pr checkout will fetch from fork repository");
});

it("should export GH_AW_IS_FORK_PR=true for fork PRs", async () => {
mockContext.eventName = "pull_request_target";
mockContext.payload.pull_request.head.repo.full_name = "fork-owner/test-repo";

await runScript();

// Verify environment variable is exported
expect(mockCore.exportVariable).toHaveBeenCalledWith("GH_AW_IS_FORK_PR", "true");
expect(mockCore.info).toHaveBeenCalledWith("Exported GH_AW_IS_FORK_PR=true");
});

it("should export GH_AW_IS_FORK_PR=false for same-repo PRs", async () => {
mockContext.eventName = "pull_request";
mockContext.payload.pull_request.head.repo.full_name = "test-owner/test-repo";
mockContext.payload.pull_request.head.repo.fork = false;

await runScript();

// Verify environment variable is exported
expect(mockCore.exportVariable).toHaveBeenCalledWith("GH_AW_IS_FORK_PR", "false");
expect(mockCore.info).toHaveBeenCalledWith("Exported GH_AW_IS_FORK_PR=false");
});

it("should log detailed PR context with startGroup/endGroup", async () => {
await runScript();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,13 @@ describe("create_discussion category normalization", () => {
});

afterEach(() => {
// Restore environment
process.env = originalEnv;
// Restore environment by mutating process.env in place
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);
vi.clearAllMocks();
});

Expand Down
9 changes: 7 additions & 2 deletions actions/setup/js/create_discussion_fallback.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,13 @@ describe("create_discussion fallback with close_older_discussions", () => {
});

afterEach(() => {
// Restore environment
process.env = originalEnv;
// Restore environment by mutating process.env in place
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);
vi.clearAllMocks();
});

Expand Down
9 changes: 7 additions & 2 deletions actions/setup/js/create_discussion_labels.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,13 @@ describe("create_discussion with labels", () => {
});

afterEach(() => {
// Restore environment
process.env = originalEnv;
// Restore environment by mutating process.env in place
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);

// Clear mocks
vi.clearAllMocks();
Expand Down
10 changes: 7 additions & 3 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @ts-check
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createRequire } from "module";

Expand Down Expand Up @@ -81,8 +80,13 @@ describe("create_issue", () => {
});

afterEach(() => {
// Restore original environment
process.env = originalEnv;
// Restore environment by mutating process.env in place
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);
vi.clearAllMocks();
});

Expand Down
38 changes: 34 additions & 4 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@ async function main(config = {}) {
core.info(`Created new branch from base: ${branchName}`);

// Apply the patch using git CLI (skip if empty)
// Track number of new commits pushed so we can restrict the extra empty commit
// to branches with exactly one new commit (security: prevents use of CI trigger
// token on multi-commit branches where workflow files may have been modified).
let newCommitCount = 0;
if (!isEmpty) {
core.info("Applying patch...");

Expand All @@ -565,8 +569,10 @@ async function main(config = {}) {
}

// Patches are created with git format-patch, so use git am to apply them
// Use --3way to handle cross-repo patches where the patch base may differ from target repo
// This allows git to resolve create-vs-modify mismatches when a file exists in target but not source
try {
await exec.exec(`git am ${patchFilePath}`);
await exec.exec(`git am --3way ${patchFilePath}`);
core.info("Patch applied successfully");
} catch (patchError) {
core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`);
Expand Down Expand Up @@ -616,6 +622,17 @@ async function main(config = {}) {

await exec.exec(`git push origin ${branchName}`);
core.info("Changes pushed to branch");

// Count new commits on PR branch relative to base, used to restrict
// the extra empty CI-trigger commit to exactly 1 new commit.
try {
const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
newCommitCount = parseInt(countStr.trim(), 10);
core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
} catch {
// Non-fatal - newCommitCount stays 0, extra empty commit will be skipped
core.info("Could not count new commits - extra empty commit will be skipped");
}
} catch (pushError) {
// Push failed - create fallback issue instead of PR (if fallback is enabled)
core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
Expand Down Expand Up @@ -664,8 +681,8 @@ To apply the patch locally:
gh run download ${runId} -n agent-artifacts -D /tmp/agent-artifacts-${runId}

# The patch file will be at agent-artifacts/tmp/gh-aw/${patchFileName} after download
# Apply the patch
git am /tmp/agent-artifacts-${runId}/${patchFileName}
# Apply the patch (--3way handles cross-repo patches where files may already exist)
git am --3way /tmp/agent-artifacts-${runId}/${patchFileName}
\`\`\`
${patchPreview}`;

Expand Down Expand Up @@ -750,6 +767,16 @@ ${patchPreview}`;

await exec.exec(`git push origin ${branchName}`);
core.info("Empty branch pushed successfully");

// Count new commits (will be 1 from the Initialize commit)
try {
const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
newCommitCount = parseInt(countStr.trim(), 10);
core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
} catch {
// Non-fatal - newCommitCount stays 0, extra empty commit will be skipped
core.info("Could not count new commits - extra empty commit will be skipped");
}
} catch (pushError) {
const error = `Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
core.error(error);
Expand Down Expand Up @@ -840,12 +867,15 @@ ${patchPreview}`;
)
.write();

// Push an extra empty commit if a token is configured.
// Push an extra empty commit if a token is configured and exactly 1 new commit was pushed.
// This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events.
// Restricting to exactly 1 new commit prevents the CI trigger token being used on
// multi-commit branches where workflow files may have been iteratively modified.
const ciTriggerResult = await pushExtraEmptyCommit({
branchName,
repoOwner: repoParts.owner,
repoName: repoParts.repo,
newCommitCount,
});
if (ciTriggerResult.success && !ciTriggerResult.skipped) {
core.info("Extra empty commit pushed - CI checks should start shortly");
Expand Down
10 changes: 9 additions & 1 deletion actions/setup/js/extra_empty_commit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,24 @@
* @param {string} options.repoOwner - Repository owner
* @param {string} options.repoName - Repository name
* @param {string} [options.commitMessage] - Custom commit message (default: "ci: trigger CI checks")
* @param {number} [options.newCommitCount] - Number of new commits being pushed. Only pushes the
* empty commit when exactly 1 new commit was pushed, preventing accidental workflow-file
* modifications on multi-commit branches and reducing loop risk.
* @returns {Promise<{success: boolean, skipped?: boolean, error?: string}>}
*/
async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage }) {
async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage, newCommitCount }) {
const token = process.env.GH_AW_CI_TRIGGER_TOKEN;

if (!token || !token.trim()) {
core.info("No extra empty commit token configured - skipping");
return { success: true, skipped: true };
}

if (newCommitCount !== undefined && newCommitCount !== 1) {
core.info(`Skipping extra empty commit: ${newCommitCount} new commit(s) pushed (only triggers for exactly 1 commit)`);
return { success: true, skipped: true };
}

core.info("Extra empty commit token detected - pushing empty commit to trigger CI events");

try {
Expand Down
72 changes: 72 additions & 0 deletions actions/setup/js/extra_empty_commit.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,78 @@ describe("extra_empty_commit.cjs", () => {
});
});

// ──────────────────────────────────────────────────────
// newCommitCount restriction
// ──────────────────────────────────────────────────────

describe("newCommitCount restriction", () => {
beforeEach(() => {
process.env.GH_AW_CI_TRIGGER_TOKEN = "ghp_test_token_123";
// No empty commits in log (so cycle prevention doesn't interfere)
mockGitLogOutput("COMMIT:abc123\nfile.txt\n");
({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"));
});

it("should proceed when newCommitCount is exactly 1", async () => {
const result = await pushExtraEmptyCommit({
branchName: "feature-branch",
repoOwner: "test-owner",
repoName: "test-repo",
newCommitCount: 1,
});

expect(result).toEqual({ success: true });
// Should have committed and pushed
const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
expect(commitCall).toBeDefined();
});

it("should skip when newCommitCount is 0", async () => {
const result = await pushExtraEmptyCommit({
branchName: "feature-branch",
repoOwner: "test-owner",
repoName: "test-repo",
newCommitCount: 0,
});

expect(result).toEqual({ success: true, skipped: true });
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("0 new commit(s)"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("only triggers for exactly 1 commit"));
// Should NOT have committed
const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
expect(commitCall).toBeUndefined();
});

it("should skip when newCommitCount is 2", async () => {
const result = await pushExtraEmptyCommit({
branchName: "feature-branch",
repoOwner: "test-owner",
repoName: "test-repo",
newCommitCount: 2,
});

expect(result).toEqual({ success: true, skipped: true });
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("2 new commit(s)"));
// Should NOT have committed
const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
expect(commitCall).toBeUndefined();
});

it("should proceed when newCommitCount is not provided (backward compatibility)", async () => {
const result = await pushExtraEmptyCommit({
branchName: "feature-branch",
repoOwner: "test-owner",
repoName: "test-repo",
// newCommitCount omitted
});

expect(result).toEqual({ success: true });
// Should have committed (no restriction when parameter is absent)
const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
expect(commitCall).toBeDefined();
});
});

// ──────────────────────────────────────────────────────
// Error handling
// ──────────────────────────────────────────────────────
Expand Down
Loading
Loading