diff --git a/actions/setup/js/extra_empty_commit.cjs b/actions/setup/js/extra_empty_commit.cjs index 185af5fad4..6819bf8b5b 100644 --- a/actions/setup/js/extra_empty_commit.cjs +++ b/actions/setup/js/extra_empty_commit.cjs @@ -9,10 +9,12 @@ * 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). Supported values: - * - `app` - Use GitHub App token from safe-outputs-app-token step - * - `default` - Use the magic secret GH_AW_CI_TRIGGER_TOKEN - * - `${{ secrets.CUSTOM_TOKEN }}` - Use a custom PAT or secret + * and is passed in as the GH_AW_CI_TRIGGER_TOKEN environment variable. + * By the time this script runs, GH_AW_CI_TRIGGER_TOKEN must contain an actual + * GitHub authentication token (for example, a GitHub App token or a PAT). + * Any selection or defaulting behavior (such as resolving `app`, `default`, + * or a specific secret reference) is handled in the workflow compiler/config + * layer before this script is invoked. */ /** @@ -28,7 +30,7 @@ * @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; + const token = process.env.GH_AW_CI_TRIGGER_TOKEN; if (!token || !token.trim()) { core.info("No extra empty commit token configured - skipping"); diff --git a/actions/setup/js/extra_empty_commit.test.cjs b/actions/setup/js/extra_empty_commit.test.cjs index 3ba863a64b..d3b69e7488 100644 --- a/actions/setup/js/extra_empty_commit.test.cjs +++ b/actions/setup/js/extra_empty_commit.test.cjs @@ -7,7 +7,7 @@ describe("extra_empty_commit.cjs", () => { let originalEnv; beforeEach(() => { - originalEnv = process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN; + originalEnv = process.env.GH_AW_CI_TRIGGER_TOKEN; mockCore = { info: vi.fn(), @@ -30,9 +30,9 @@ describe("extra_empty_commit.cjs", () => { afterEach(() => { if (originalEnv !== undefined) { - process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = originalEnv; + process.env.GH_AW_CI_TRIGGER_TOKEN = originalEnv; } else { - delete process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN; + delete process.env.GH_AW_CI_TRIGGER_TOKEN; } delete global.core; delete global.exec; @@ -59,7 +59,7 @@ describe("extra_empty_commit.cjs", () => { 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; + delete process.env.GH_AW_CI_TRIGGER_TOKEN; ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs")); const result = await pushExtraEmptyCommit({ @@ -74,7 +74,7 @@ describe("extra_empty_commit.cjs", () => { }); it("should skip when token is empty string", async () => { - process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = ""; + process.env.GH_AW_CI_TRIGGER_TOKEN = ""; ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs")); const result = await pushExtraEmptyCommit({ @@ -87,7 +87,7 @@ describe("extra_empty_commit.cjs", () => { }); it("should skip when token is whitespace only", async () => { - process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = " "; + process.env.GH_AW_CI_TRIGGER_TOKEN = " "; ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs")); const result = await pushExtraEmptyCommit({ @@ -106,7 +106,7 @@ describe("extra_empty_commit.cjs", () => { describe("when token is set and no cycle issues", () => { beforeEach(() => { - process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "ghp_test_token_123"; + process.env.GH_AW_CI_TRIGGER_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); @@ -187,7 +187,7 @@ describe("extra_empty_commit.cjs", () => { describe("cycle prevention", () => { beforeEach(() => { - process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "ghp_test_token_123"; + process.env.GH_AW_CI_TRIGGER_TOKEN = "ghp_test_token_123"; ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs")); }); @@ -327,7 +327,7 @@ describe("extra_empty_commit.cjs", () => { describe("error handling", () => { beforeEach(() => { - process.env.GH_AW_EXTRA_EMPTY_COMMIT_TOKEN = "ghp_test_token_123"; + process.env.GH_AW_CI_TRIGGER_TOKEN = "ghp_test_token_123"; // No empty commits in log mockGitLogOutput("COMMIT:abc123\nfile.txt\n"); ({ pushExtraEmptyCommit } = require("./extra_empty_commit.cjs")); diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index f7449cf915..b245c46dfe 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5169,7 +5169,7 @@ }, "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 }}'), 'app' for GitHub App auth, or 'default' to use the magic secret GH_AW_CI_TRIGGER_TOKEN." + "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. Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the repository. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a custom token, or 'app' for GitHub App auth." } }, "additionalProperties": false, @@ -6150,7 +6150,7 @@ }, "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 }}'), 'app' for GitHub App auth, or 'default' to use the magic secret GH_AW_CI_TRIGGER_TOKEN." + "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. Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the repository. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a custom 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 0c3dc99050..bbc44021a3 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -409,22 +409,23 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID // 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 + var ciTriggerToken string if data.SafeOutputs.CreatePullRequests != nil && data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit != "" { - extraEmptyCommitToken = data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit + ciTriggerToken = data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit } else if data.SafeOutputs.PushToPullRequestBranch != nil && data.SafeOutputs.PushToPullRequestBranch.GithubTokenForExtraEmptyCommit != "" { - extraEmptyCommitToken = data.SafeOutputs.PushToPullRequestBranch.GithubTokenForExtraEmptyCommit + ciTriggerToken = data.SafeOutputs.PushToPullRequestBranch.GithubTokenForExtraEmptyCommit } - if extraEmptyCommitToken == "app" { - envVars["GH_AW_EXTRA_EMPTY_COMMIT_TOKEN"] = "${{ steps.safe-outputs-app-token.outputs.token || '' }}" + switch ciTriggerToken { + case "app": + envVars["GH_AW_CI_TRIGGER_TOKEN"] = "${{ steps.safe-outputs-app-token.outputs.token || '' }}" consolidatedSafeOutputsJobLog.Print("Extra empty commit using GitHub App token") - } else if extraEmptyCommitToken == "default" { - // Use the magic GH_AW_CI_TRIGGER_TOKEN secret as fallback - envVars["GH_AW_EXTRA_EMPTY_COMMIT_TOKEN"] = getEffectiveCITriggerGitHubToken("") + case "default", "": + // Use the magic GH_AW_CI_TRIGGER_TOKEN secret (default behavior when not explicitly configured) + envVars["GH_AW_CI_TRIGGER_TOKEN"] = getEffectiveCITriggerGitHubToken("") consolidatedSafeOutputsJobLog.Print("Extra empty commit using GH_AW_CI_TRIGGER_TOKEN") - } else if extraEmptyCommitToken != "" { - envVars["GH_AW_EXTRA_EMPTY_COMMIT_TOKEN"] = extraEmptyCommitToken + default: + envVars["GH_AW_CI_TRIGGER_TOKEN"] = ciTriggerToken consolidatedSafeOutputsJobLog.Print("Extra empty commit using explicit token") } } diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 5f8a31c01d..d0e6e474e6 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -165,20 +165,20 @@ 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 if extraEmptyCommitToken == "default" { - // Use the magic GH_AW_CI_TRIGGER_TOKEN secret as fallback - customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_EXTRA_EMPTY_COMMIT_TOKEN: %s\n", getEffectiveCITriggerGitHubToken(""))) - createPRLog.Print("Extra empty commit using GH_AW_CI_TRIGGER_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 extra empty commit token (for pushing an empty commit to trigger CI) + // Defaults to GH_AW_CI_TRIGGER_TOKEN when not explicitly configured + ciTriggerToken := data.SafeOutputs.CreatePullRequests.GithubTokenForExtraEmptyCommit + switch ciTriggerToken { + case "app": + customEnvVars = append(customEnvVars, " GH_AW_CI_TRIGGER_TOKEN: ${{ steps.safe-outputs-app-token.outputs.token || '' }}\n") + createPRLog.Print("Extra empty commit using GitHub App token") + case "default", "": + // Use the magic GH_AW_CI_TRIGGER_TOKEN secret (default behavior when not explicitly configured) + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_CI_TRIGGER_TOKEN: %s\n", getEffectiveCITriggerGitHubToken(""))) + createPRLog.Print("Extra empty commit using GH_AW_CI_TRIGGER_TOKEN") + default: + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_CI_TRIGGER_TOKEN: %s\n", ciTriggerToken)) + createPRLog.Printf("Extra empty commit using explicit token") } // Add standard environment variables (metadata + staged/target repo) diff --git a/pkg/workflow/create_pull_request_ci_trigger_token_test.go b/pkg/workflow/create_pull_request_ci_trigger_token_test.go new file mode 100644 index 0000000000..ca1fc35c15 --- /dev/null +++ b/pkg/workflow/create_pull_request_ci_trigger_token_test.go @@ -0,0 +1,215 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCreatePullRequestCITriggerToken verifies the GH_AW_CI_TRIGGER_TOKEN env var +// is correctly generated in the safe_outputs job for different configurations: +// - unset/empty: uses secrets.GH_AW_CI_TRIGGER_TOKEN +// - "app": uses steps.safe-outputs-app-token.outputs.token +// - explicit token: uses the specified token value +func TestCreatePullRequestCITriggerToken(t *testing.T) { + tests := []struct { + name string + tokenConfig string // value for github-token-for-extra-empty-commit + expectedContains string // expected substring in GH_AW_CI_TRIGGER_TOKEN env var + notExpected string // should NOT contain this string + }{ + { + name: "unset config uses secrets.GH_AW_CI_TRIGGER_TOKEN", + tokenConfig: "", + expectedContains: "${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}", + notExpected: "safe-outputs-app-token", + }, + { + name: "default config uses secrets.GH_AW_CI_TRIGGER_TOKEN", + tokenConfig: "default", + expectedContains: "${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}", + notExpected: "safe-outputs-app-token", + }, + { + name: "app config uses app token step output", + tokenConfig: "app", + expectedContains: "${{ steps.safe-outputs-app-token.outputs.token || '' }}", + notExpected: "secrets.GH_AW_CI_TRIGGER_TOKEN", + }, + { + name: "explicit token uses provided value", + tokenConfig: "${{ secrets.MY_CUSTOM_PAT }}", + expectedContains: "${{ secrets.MY_CUSTOM_PAT }}", + notExpected: "safe-outputs-app-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "ci-trigger-token-test") + + // Build the workflow content with or without the token config + var safeOutputsConfig string + if tt.tokenConfig == "" { + safeOutputsConfig = `safe-outputs: + create-pull-request: + title-prefix: "[test] " + labels: [test]` + } else { + safeOutputsConfig = `safe-outputs: + create-pull-request: + title-prefix: "[test] " + labels: [test] + github-token-for-extra-empty-commit: ` + tt.tokenConfig + } + + testContent := `--- +on: push +permissions: + contents: read + pull-requests: write + issues: read +tools: + github: + allowed: [list_issues] +engine: claude +features: + dangerous-permissions-write: true +strict: false +` + safeOutputsConfig + ` +--- + +# Test CI Trigger Token Configuration + +This workflow tests the GH_AW_CI_TRIGGER_TOKEN env var generation. +` + + testFile := filepath.Join(tmpDir, "test-ci-trigger-token.md") + err := os.WriteFile(testFile, []byte(testContent), 0644) + require.NoError(t, err, "Failed to write test file") + + compiler := NewCompiler() + + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Should compile workflow without error") + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err, "Should read generated lock file") + + lockContentStr := string(lockContent) + + // Verify the expected token configuration is present + assert.Contains(t, lockContentStr, "GH_AW_CI_TRIGGER_TOKEN:", + "Generated workflow should contain GH_AW_CI_TRIGGER_TOKEN env var") + + assert.Contains(t, lockContentStr, tt.expectedContains, + "GH_AW_CI_TRIGGER_TOKEN should have expected value") + + if tt.notExpected != "" { + // Find the GH_AW_CI_TRIGGER_TOKEN line and verify it doesn't contain the unexpected value + for line := range strings.SplitSeq(lockContentStr, "\n") { + if strings.Contains(line, "GH_AW_CI_TRIGGER_TOKEN:") { + assert.NotContains(t, line, tt.notExpected, + "GH_AW_CI_TRIGGER_TOKEN should not contain %q", tt.notExpected) + } + } + } + }) + } +} + +// TestPushToPullRequestBranchCITriggerToken verifies the GH_AW_CI_TRIGGER_TOKEN env var +// is correctly generated for push-to-pull-request-branch safe output configuration. +func TestPushToPullRequestBranchCITriggerToken(t *testing.T) { + tests := []struct { + name string + tokenConfig string + expectedContains string + }{ + { + name: "unset config uses secrets.GH_AW_CI_TRIGGER_TOKEN", + tokenConfig: "", + expectedContains: "${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}", + }, + { + name: "app config uses app token step output", + tokenConfig: "app", + expectedContains: "${{ steps.safe-outputs-app-token.outputs.token || '' }}", + }, + { + name: "explicit token uses provided value", + tokenConfig: "${{ secrets.CUSTOM_PUSH_TOKEN }}", + expectedContains: "${{ secrets.CUSTOM_PUSH_TOKEN }}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "push-pr-branch-ci-trigger-test") + + var safeOutputsConfig string + if tt.tokenConfig == "" { + safeOutputsConfig = `safe-outputs: + push-to-pull-request-branch: + labels: [test]` + } else { + safeOutputsConfig = `safe-outputs: + push-to-pull-request-branch: + labels: [test] + github-token-for-extra-empty-commit: ` + tt.tokenConfig + } + + testContent := `--- +on: + pull_request: + types: [opened] +permissions: + contents: read + pull-requests: write +tools: + github: + allowed: [list_issues] +engine: claude +features: + dangerous-permissions-write: true +strict: false +` + safeOutputsConfig + ` +--- + +# Test Push to PR Branch CI Trigger Token + +This workflow tests push-to-pull-request-branch token configuration. +` + + testFile := filepath.Join(tmpDir, "test-push-pr-branch-token.md") + err := os.WriteFile(testFile, []byte(testContent), 0644) + require.NoError(t, err, "Failed to write test file") + + compiler := NewCompiler() + + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Should compile workflow without error") + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err, "Should read generated lock file") + + lockContentStr := string(lockContent) + + assert.Contains(t, lockContentStr, "GH_AW_CI_TRIGGER_TOKEN:", + "Generated workflow should contain GH_AW_CI_TRIGGER_TOKEN env var") + + assert.Contains(t, lockContentStr, tt.expectedContains, + "GH_AW_CI_TRIGGER_TOKEN should have expected value") + }) + } +}