From fccd268692587be34ebab6ef0f1b9111c7c542ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:39:27 +0000 Subject: [PATCH 1/7] Initial plan From c3577be00cef49ea27b3a5f07edd08dad992bfcb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:05:02 +0000 Subject: [PATCH 2/7] fix: ignore-if-missing no longer emits invalid secrets context in if: expressions GitHub Actions only allows github, needs, vars, env, inputs, steps, and runner contexts in if: expressions. The secrets context is not allowed. When buildIgnoreIfMissingCondition generates the step guard expression for ignore-if-missing: true, it now filters out any wrapped ${{ secrets.* }} values. Only expressions with valid if: contexts (vars, env, inputs, github, steps, runner, etc.) are included in the condition. When all credential values use the secrets context, no if: condition is emitted and the step runs unconditionally (same as the workaround users had found). Fixes: github/gh-aw#44709 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/activation_github_token_test.go | 4 +- pkg/workflow/compiler_activation_daily_aic.go | 6 ++- pkg/workflow/compiler_pre_activation_job.go | 4 +- pkg/workflow/safe_outputs_app_config.go | 53 ++++++++++++++++--- pkg/workflow/safe_outputs_app_test.go | 20 +++++-- pkg/workflow/skip_if_match_test.go | 6 ++- 6 files changed, 78 insertions(+), 15 deletions(-) diff --git a/pkg/workflow/activation_github_token_test.go b/pkg/workflow/activation_github_token_test.go index 2d8b769f919..b23f9fcd8b8 100644 --- a/pkg/workflow/activation_github_token_test.go +++ b/pkg/workflow/activation_github_token_test.go @@ -166,7 +166,9 @@ func TestActivationGitHubApp(t *testing.T) { require.NotNil(t, job) stepsStr := strings.Join(job.Steps, "") - assert.Contains(t, stepsStr, "if: ${{ secrets.GH_AW_APP_ID != '' && secrets.GH_AW_APP_PRIVATE_KEY != '' }}") + // Both credentials use secrets.* which is not valid in if: expressions — + // no if: guard should be emitted; the step runs unconditionally. + assert.NotContains(t, stepsStr, "if: ${{ secrets.") assert.NotContains(t, stepsStr, "GH_AW_APP_CLIENT_ID:") assert.NotContains(t, stepsStr, "GH_AW_APP_PRIVATE_KEY:") assert.Contains(t, stepsStr, "github-token: ${{ steps.activation-app-token.outputs.token || secrets.GITHUB_TOKEN }}") diff --git a/pkg/workflow/compiler_activation_daily_aic.go b/pkg/workflow/compiler_activation_daily_aic.go index c9315b6c633..7a2f38fe2c1 100644 --- a/pkg/workflow/compiler_activation_daily_aic.go +++ b/pkg/workflow/compiler_activation_daily_aic.go @@ -25,7 +25,11 @@ func (c *Compiler) buildDailyAICAppTokenMintStep(app *GitHubAppConfig) []string steps = append(steps, " - name: Generate GitHub App token for daily AIC guardrail\n") steps = append(steps, fmt.Sprintf(" id: %s\n", dailyAICAppTokenStepID)) if app.shouldIgnoreMissingKey() { - steps = append(steps, fmt.Sprintf(" if: %s && %s\n", maxDailyAICreditsConfiguredIfExpr, buildIgnoreIfMissingCondition(app))) + if condition := buildIgnoreIfMissingCondition(app); condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s && %s\n", maxDailyAICreditsConfiguredIfExpr, condition)) + } else { + steps = append(steps, fmt.Sprintf(" if: %s\n", maxDailyAICreditsConfiguredIfExpr)) + } } else { steps = append(steps, fmt.Sprintf(" if: %s\n", maxDailyAICreditsConfiguredIfExpr)) } diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 4aaddfc1fd0..2911a07647d 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -753,7 +753,9 @@ func (c *Compiler) buildPreActivationAppTokenMintStep(app *GitHubAppConfig) []st steps = append(steps, " - name: Generate GitHub App token for skip-if checks\n") steps = append(steps, fmt.Sprintf(" id: %s\n", tokenStepID)) if app.shouldIgnoreMissingKey() { - steps = append(steps, fmt.Sprintf(" if: %s\n", buildIgnoreIfMissingCondition(app))) + if condition := buildIgnoreIfMissingCondition(app); condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) + } } steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/create-github-app-token"))) steps = append(steps, " with:\n") diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index d77b1e6ff21..d2749d9250b 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -144,13 +144,48 @@ func buildGitHubExpressionNonEmptyCheck(value string) ConditionNode { return BuildNotEquals(BuildStringLiteral(strings.TrimSpace(githubExpressionWhitespaceReplacer.Replace(trimmed))), BuildStringLiteral("")) } +// ifInvalidContextPrefixes lists the GitHub Actions expression context prefixes that +// are not available in 'if:' conditions. GitHub Actions only allows the github, needs, +// vars, env, inputs, steps, and runner contexts in if: expressions. +var ifInvalidContextPrefixes = []string{"secrets.", "jobs.", "matrix."} + +// isValidIfContextExpression returns true if the inner expression (the text inside +// ${{ }}) can be safely used inside a GitHub Actions 'if:' condition. +func isValidIfContextExpression(inner string) bool { + trimmed := strings.TrimSpace(inner) + for _, prefix := range ifInvalidContextPrefixes { + if strings.HasPrefix(trimmed, prefix) { + return false + } + } + return true +} + // buildIgnoreIfMissingCondition returns a GitHub Actions if-expression that requires -// both GitHub App credential inputs to be non-empty. +// all GitHub App credential inputs that can be checked in an if: condition to be non-empty. +// Values referencing the 'secrets' context are excluded because GitHub Actions does not +// allow the secrets context in if: expressions (only github, needs, vars, env, inputs, +// steps, and runner are valid). +// Returns an empty string when no checkable expressions remain (e.g. both inputs use secrets.*). func buildIgnoreIfMissingCondition(app *GitHubAppConfig) string { - condition := BuildAnd( - buildGitHubExpressionNonEmptyCheck(app.AppID), - buildGitHubExpressionNonEmptyCheck(app.PrivateKey), - ) + var checks []ConditionNode + for _, value := range []string{app.AppID, app.PrivateKey} { + trimmed := strings.TrimSpace(value) + if inner, ok := extractWrappedGitHubExpression(trimmed); ok { + if !isValidIfContextExpression(inner) { + safeOutputsAppLog.Printf("Skipping %q in ignore-if-missing condition: context not valid in if: expressions", inner) + continue + } + } + checks = append(checks, buildGitHubExpressionNonEmptyCheck(value)) + } + if len(checks) == 0 { + return "" + } + condition := checks[0] + for i := 1; i < len(checks); i++ { + condition = BuildAnd(condition, checks[i]) + } return wrapGitHubExpression(RenderCondition(condition)) } @@ -226,7 +261,9 @@ func (c *Compiler) buildGitHubAppTokenMintStepWithMeta(app *GitHubAppConfig, per steps = append(steps, fmt.Sprintf(" - name: %s\n", stepName)) steps = append(steps, fmt.Sprintf(" id: %s\n", stepID)) if app.shouldIgnoreMissingKey() { - steps = append(steps, fmt.Sprintf(" if: %s\n", buildIgnoreIfMissingCondition(app))) + if condition := buildIgnoreIfMissingCondition(app); condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) + } } steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/create-github-app-token"))) steps = append(steps, " with:\n") @@ -486,7 +523,9 @@ func (c *Compiler) buildActivationAppTokenMintStep(app *GitHubAppConfig, permiss steps = append(steps, " - name: Generate GitHub App token for activation\n") steps = append(steps, " id: activation-app-token\n") if app.shouldIgnoreMissingKey() { - steps = append(steps, fmt.Sprintf(" if: %s\n", buildIgnoreIfMissingCondition(app))) + if condition := buildIgnoreIfMissingCondition(app); condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) + } } steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/create-github-app-token"))) steps = append(steps, " with:\n") diff --git a/pkg/workflow/safe_outputs_app_test.go b/pkg/workflow/safe_outputs_app_test.go index 9683f752e10..56021d8b07b 100644 --- a/pkg/workflow/safe_outputs_app_test.go +++ b/pkg/workflow/safe_outputs_app_test.go @@ -117,7 +117,9 @@ safe-outputs: require.NotNil(t, job, "Job should not be nil") stepsStr := strings.Join(job.Steps, "") - assert.Contains(t, stepsStr, "if: ${{ secrets.GH_AW_APP_ID != '' && secrets.GH_AW_APP_PRIVATE_KEY != '' }}") + // Both credentials use secrets.* context which is not valid in if: expressions, + // so no if: condition should be generated for the ignore-if-missing token mint step. + assert.NotContains(t, stepsStr, "if: ${{ secrets.") assert.NotContains(t, stepsStr, "GH_AW_APP_CLIENT_ID:") assert.NotContains(t, stepsStr, "GH_AW_APP_PRIVATE_KEY:") assert.Contains(t, stepsStr, "github-token: ${{ steps.safe-outputs-app-token.outputs.token || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}") @@ -143,10 +145,22 @@ func TestBuildIgnoreIfMissingCondition(t *testing.T) { expected string }{ { - name: "wrapped expressions", + name: "both secrets - returns empty (secrets context not valid in if:)", appID: "${{ secrets.GH_AW_APP_ID }}", privateKey: "${{ secrets.GH_AW_APP_PRIVATE_KEY }}", - expected: "${{ secrets.GH_AW_APP_ID != '' && secrets.GH_AW_APP_PRIVATE_KEY != '' }}", + expected: "", + }, + { + name: "vars client-id + secrets private-key - only vars checked", + appID: "${{ vars.APP_CLIENT_ID }}", + privateKey: "${{ secrets.APP_PRIVATE_KEY }}", + expected: "${{ vars.APP_CLIENT_ID != '' }}", + }, + { + name: "both vars", + appID: "${{ vars.APP_CLIENT_ID }}", + privateKey: "${{ vars.APP_PRIVATE_KEY }}", + expected: "${{ vars.APP_CLIENT_ID != '' && vars.APP_PRIVATE_KEY != '' }}", }, { name: "literal values", diff --git a/pkg/workflow/skip_if_match_test.go b/pkg/workflow/skip_if_match_test.go index eacef972dd1..a5b1420ab69 100644 --- a/pkg/workflow/skip_if_match_test.go +++ b/pkg/workflow/skip_if_match_test.go @@ -485,8 +485,10 @@ engine: claude } lockContentStr := string(lockContent) - if !strings.Contains(lockContentStr, "if: ${{ secrets.WORKFLOW_APP_ID != '' && secrets.WORKFLOW_APP_PRIVATE_KEY != '' }}") { - t.Error("Expected guard to check app secrets directly when ignore-if-missing is enabled") + // Both credentials use secrets.* which is not valid in if: expressions — + // no if: guard should be emitted on the token mint step. + if strings.Contains(lockContentStr, "if: ${{ secrets.") { + t.Error("Expected no guard using secrets context: secrets is not valid in if: expressions") } if strings.Contains(lockContentStr, "GH_AW_APP_CLIENT_ID:") { t.Error("Did not expect step-local GH_AW_APP_CLIENT_ID env in mint step guard") From cc4b8f4a1b70754da262af91b05d5755ac18dedc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:06:16 +0000 Subject: [PATCH 3/7] docs: add GitHub Actions context availability reference to ifInvalidContextPrefixes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_outputs_app_config.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index d2749d9250b..4612f291379 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -146,7 +146,10 @@ func buildGitHubExpressionNonEmptyCheck(value string) ConditionNode { // ifInvalidContextPrefixes lists the GitHub Actions expression context prefixes that // are not available in 'if:' conditions. GitHub Actions only allows the github, needs, -// vars, env, inputs, steps, and runner contexts in if: expressions. +// vars, env, inputs, steps, and runner contexts in if: expressions — the secrets, +// jobs, and matrix contexts are evaluated later in the workflow lifecycle and are +// therefore not accessible at the condition-evaluation stage. +// See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#contexts var ifInvalidContextPrefixes = []string{"secrets.", "jobs.", "matrix."} // isValidIfContextExpression returns true if the inner expression (the text inside From 1a4a53ca028d6a6e9aa4558a618c31d09b712f5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:08:17 +0000 Subject: [PATCH 4/7] fix: preserve ignore-if-missing guards for app credentials Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/activation_github_token_test.go | 9 +- pkg/workflow/compiler_activation_daily_aic.go | 6 +- pkg/workflow/compiler_pre_activation_job.go | 6 +- pkg/workflow/safe_outputs_app_config.go | 162 ++++++++++++++---- pkg/workflow/safe_outputs_app_test.go | 69 ++++++-- pkg/workflow/skip_if_match_test.go | 15 +- 6 files changed, 211 insertions(+), 56 deletions(-) diff --git a/pkg/workflow/activation_github_token_test.go b/pkg/workflow/activation_github_token_test.go index b23f9fcd8b8..ff5d968e7fd 100644 --- a/pkg/workflow/activation_github_token_test.go +++ b/pkg/workflow/activation_github_token_test.go @@ -166,11 +166,12 @@ func TestActivationGitHubApp(t *testing.T) { require.NotNil(t, job) stepsStr := strings.Join(job.Steps, "") - // Both credentials use secrets.* which is not valid in if: expressions — - // no if: guard should be emitted; the step runs unconditionally. + // Both credentials use secrets.* so ignore-if-missing should guard on + // step-local env aliases instead of secrets.* directly. assert.NotContains(t, stepsStr, "if: ${{ secrets.") - assert.NotContains(t, stepsStr, "GH_AW_APP_CLIENT_ID:") - assert.NotContains(t, stepsStr, "GH_AW_APP_PRIVATE_KEY:") + assert.Contains(t, stepsStr, "GH_AW_IGNORE_IF_MISSING_APP_ID: ${{ secrets.GH_AW_APP_ID }}") + assert.Contains(t, stepsStr, "GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY: ${{ secrets.GH_AW_APP_PRIVATE_KEY }}") + assert.Contains(t, stepsStr, "if: ${{ env.GH_AW_IGNORE_IF_MISSING_APP_ID != '' && env.GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY != '' }}") assert.Contains(t, stepsStr, "github-token: ${{ steps.activation-app-token.outputs.token || secrets.GITHUB_TOKEN }}") }) diff --git a/pkg/workflow/compiler_activation_daily_aic.go b/pkg/workflow/compiler_activation_daily_aic.go index 7a2f38fe2c1..99534aa05a5 100644 --- a/pkg/workflow/compiler_activation_daily_aic.go +++ b/pkg/workflow/compiler_activation_daily_aic.go @@ -25,8 +25,10 @@ func (c *Compiler) buildDailyAICAppTokenMintStep(app *GitHubAppConfig) []string steps = append(steps, " - name: Generate GitHub App token for daily AIC guardrail\n") steps = append(steps, fmt.Sprintf(" id: %s\n", dailyAICAppTokenStepID)) if app.shouldIgnoreMissingKey() { - if condition := buildIgnoreIfMissingCondition(app); condition != "" { - steps = append(steps, fmt.Sprintf(" if: %s && %s\n", maxDailyAICreditsConfiguredIfExpr, condition)) + guard := buildIgnoreIfMissingCondition(app) + steps = appendStepEnvAssignments(steps, guard.EnvAssignments) + if condition := combineGitHubIfExpressions(maxDailyAICreditsConfiguredIfExpr, guard.Condition); condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) } else { steps = append(steps, fmt.Sprintf(" if: %s\n", maxDailyAICreditsConfiguredIfExpr)) } diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 2911a07647d..174d45b5ad7 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -753,8 +753,10 @@ func (c *Compiler) buildPreActivationAppTokenMintStep(app *GitHubAppConfig) []st steps = append(steps, " - name: Generate GitHub App token for skip-if checks\n") steps = append(steps, fmt.Sprintf(" id: %s\n", tokenStepID)) if app.shouldIgnoreMissingKey() { - if condition := buildIgnoreIfMissingCondition(app); condition != "" { - steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) + guard := buildIgnoreIfMissingCondition(app) + steps = appendStepEnvAssignments(steps, guard.EnvAssignments) + if guard.Condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", guard.Condition)) } } steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/create-github-app-token"))) diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 4612f291379..b38810d991a 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -144,52 +144,148 @@ func buildGitHubExpressionNonEmptyCheck(value string) ConditionNode { return BuildNotEquals(BuildStringLiteral(strings.TrimSpace(githubExpressionWhitespaceReplacer.Replace(trimmed))), BuildStringLiteral("")) } -// ifInvalidContextPrefixes lists the GitHub Actions expression context prefixes that -// are not available in 'if:' conditions. GitHub Actions only allows the github, needs, -// vars, env, inputs, steps, and runner contexts in if: expressions — the secrets, -// jobs, and matrix contexts are evaluated later in the workflow lifecycle and are -// therefore not accessible at the condition-evaluation stage. +// ifInvalidContextNames lists the GitHub Actions expression contexts that are not +// available in step-level 'if:' conditions. GitHub Actions allows matrix in this +// position, but still rejects secrets and jobs references. // See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#contexts -var ifInvalidContextPrefixes = []string{"secrets.", "jobs.", "matrix."} - -// isValidIfContextExpression returns true if the inner expression (the text inside -// ${{ }}) can be safely used inside a GitHub Actions 'if:' condition. -func isValidIfContextExpression(inner string) bool { - trimmed := strings.TrimSpace(inner) - for _, prefix := range ifInvalidContextPrefixes { - if strings.HasPrefix(trimmed, prefix) { - return false +var ifInvalidContextNames = map[string]struct{}{ + "jobs": {}, + "secrets": {}, +} + +const ( + ignoreIfMissingAppIDEnvVar = "GH_AW_IGNORE_IF_MISSING_APP_ID" + ignoreIfMissingPrivateKeyEnvVar = "GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY" +) + +type stepEnvAssignment struct { + Name string + Value string +} + +type ignoreIfMissingGuard struct { + Condition string + EnvAssignments []stepEnvAssignment +} + +func isGitHubExpressionIdentifierChar(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' +} + +// containsInvalidIfContextReference returns true when the inner expression body +// contains a jobs or secrets context token anywhere outside single-quoted string +// literals, including bracket notation such as secrets['TOKEN']. +func containsInvalidIfContextReference(inner string) bool { + for i := 0; i < len(inner); { + if inner[i] == '\'' { + i++ + for i < len(inner) { + if inner[i] != '\'' { + i++ + continue + } + if i+1 < len(inner) && inner[i+1] == '\'' { + i += 2 + continue + } + i++ + break + } + continue + } + + if !isGitHubExpressionIdentifierChar(inner[i]) || (i > 0 && isGitHubExpressionIdentifierChar(inner[i-1])) { + i++ + continue + } + + start := i + for i < len(inner) && isGitHubExpressionIdentifierChar(inner[i]) { + i++ + } + name := inner[start:i] + if _, ok := ifInvalidContextNames[name]; !ok { + continue + } + + j := i + for j < len(inner) && (inner[j] == ' ' || inner[j] == '\t' || inner[j] == '\n' || inner[j] == '\r') { + j++ + } + if j < len(inner) && (inner[j] == '.' || inner[j] == '[') { + return true + } + } + return false +} + +func combineGitHubIfExpressions(expressions ...string) string { + var parts []string + for _, expression := range expressions { + trimmed := strings.TrimSpace(expression) + if trimmed == "" { + continue + } + if inner, ok := extractWrappedGitHubExpression(trimmed); ok { + parts = append(parts, inner) + continue } + parts = append(parts, trimmed) + } + if len(parts) == 0 { + return "" + } + return wrapGitHubExpression(strings.Join(parts, " && ")) +} + +func appendStepEnvAssignments(steps []string, assignments []stepEnvAssignment) []string { + if len(assignments) == 0 { + return steps + } + steps = append(steps, " env:\n") + for _, assignment := range assignments { + steps = append(steps, fmt.Sprintf(" %s: %s\n", assignment.Name, assignment.Value)) } - return true + return steps } // buildIgnoreIfMissingCondition returns a GitHub Actions if-expression that requires // all GitHub App credential inputs that can be checked in an if: condition to be non-empty. -// Values referencing the 'secrets' context are excluded because GitHub Actions does not -// allow the secrets context in if: expressions (only github, needs, vars, env, inputs, -// steps, and runner are valid). -// Returns an empty string when no checkable expressions remain (e.g. both inputs use secrets.*). -func buildIgnoreIfMissingCondition(app *GitHubAppConfig) string { +// Values referencing the secrets or jobs contexts are routed through step-local env +// aliases so the guard can still check them through the supported env context. +func buildIgnoreIfMissingCondition(app *GitHubAppConfig) ignoreIfMissingGuard { var checks []ConditionNode - for _, value := range []string{app.AppID, app.PrivateKey} { - trimmed := strings.TrimSpace(value) + guard := ignoreIfMissingGuard{} + for _, credential := range []struct { + value string + envName string + }{ + {value: app.AppID, envName: ignoreIfMissingAppIDEnvVar}, + {value: app.PrivateKey, envName: ignoreIfMissingPrivateKeyEnvVar}, + } { + trimmed := strings.TrimSpace(credential.value) if inner, ok := extractWrappedGitHubExpression(trimmed); ok { - if !isValidIfContextExpression(inner) { - safeOutputsAppLog.Printf("Skipping %q in ignore-if-missing condition: context not valid in if: expressions", inner) + if containsInvalidIfContextReference(inner) { + safeOutputsAppLog.Printf("Rewriting %q in ignore-if-missing condition through env.%s: context not valid in if: expressions", inner, credential.envName) + guard.EnvAssignments = append(guard.EnvAssignments, stepEnvAssignment{ + Name: credential.envName, + Value: trimmed, + }) + checks = append(checks, BuildNotEquals(&ExpressionNode{Expression: "env." + credential.envName}, BuildStringLiteral(""))) continue } } - checks = append(checks, buildGitHubExpressionNonEmptyCheck(value)) + checks = append(checks, buildGitHubExpressionNonEmptyCheck(credential.value)) } if len(checks) == 0 { - return "" + return guard } condition := checks[0] for i := 1; i < len(checks); i++ { condition = BuildAnd(condition, checks[i]) } - return wrapGitHubExpression(RenderCondition(condition)) + guard.Condition = wrapGitHubExpression(RenderCondition(condition)) + return guard } // ======================================== @@ -264,8 +360,10 @@ func (c *Compiler) buildGitHubAppTokenMintStepWithMeta(app *GitHubAppConfig, per steps = append(steps, fmt.Sprintf(" - name: %s\n", stepName)) steps = append(steps, fmt.Sprintf(" id: %s\n", stepID)) if app.shouldIgnoreMissingKey() { - if condition := buildIgnoreIfMissingCondition(app); condition != "" { - steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) + guard := buildIgnoreIfMissingCondition(app) + steps = appendStepEnvAssignments(steps, guard.EnvAssignments) + if guard.Condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", guard.Condition)) } } steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/create-github-app-token"))) @@ -526,8 +624,10 @@ func (c *Compiler) buildActivationAppTokenMintStep(app *GitHubAppConfig, permiss steps = append(steps, " - name: Generate GitHub App token for activation\n") steps = append(steps, " id: activation-app-token\n") if app.shouldIgnoreMissingKey() { - if condition := buildIgnoreIfMissingCondition(app); condition != "" { - steps = append(steps, fmt.Sprintf(" if: %s\n", condition)) + guard := buildIgnoreIfMissingCondition(app) + steps = appendStepEnvAssignments(steps, guard.EnvAssignments) + if guard.Condition != "" { + steps = append(steps, fmt.Sprintf(" if: %s\n", guard.Condition)) } } steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/create-github-app-token"))) diff --git a/pkg/workflow/safe_outputs_app_test.go b/pkg/workflow/safe_outputs_app_test.go index 56021d8b07b..88d6b22efdd 100644 --- a/pkg/workflow/safe_outputs_app_test.go +++ b/pkg/workflow/safe_outputs_app_test.go @@ -117,11 +117,12 @@ safe-outputs: require.NotNil(t, job, "Job should not be nil") stepsStr := strings.Join(job.Steps, "") - // Both credentials use secrets.* context which is not valid in if: expressions, - // so no if: condition should be generated for the ignore-if-missing token mint step. + // Both credentials use secrets.* context, so the ignore-if-missing guard must + // check step-local env aliases instead of emitting secrets.* directly in if:. assert.NotContains(t, stepsStr, "if: ${{ secrets.") - assert.NotContains(t, stepsStr, "GH_AW_APP_CLIENT_ID:") - assert.NotContains(t, stepsStr, "GH_AW_APP_PRIVATE_KEY:") + assert.Contains(t, stepsStr, "GH_AW_IGNORE_IF_MISSING_APP_ID: ${{ secrets.GH_AW_APP_ID }}") + assert.Contains(t, stepsStr, "GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY: ${{ secrets.GH_AW_APP_PRIVATE_KEY }}") + assert.Contains(t, stepsStr, "if: ${{ env.GH_AW_IGNORE_IF_MISSING_APP_ID != '' && env.GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY != '' }}") assert.Contains(t, stepsStr, "github-token: ${{ steps.safe-outputs-app-token.outputs.token || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}") } @@ -142,31 +143,77 @@ func TestBuildIgnoreIfMissingCondition(t *testing.T) { name string appID string privateKey string - expected string + expected ignoreIfMissingGuard }{ { - name: "both secrets - returns empty (secrets context not valid in if:)", + name: "both secrets - route through env aliases", appID: "${{ secrets.GH_AW_APP_ID }}", privateKey: "${{ secrets.GH_AW_APP_PRIVATE_KEY }}", - expected: "", + expected: ignoreIfMissingGuard{ + Condition: "${{ env.GH_AW_IGNORE_IF_MISSING_APP_ID != '' && env.GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY != '' }}", + EnvAssignments: []stepEnvAssignment{ + {Name: ignoreIfMissingAppIDEnvVar, Value: "${{ secrets.GH_AW_APP_ID }}"}, + {Name: ignoreIfMissingPrivateKeyEnvVar, Value: "${{ secrets.GH_AW_APP_PRIVATE_KEY }}"}, + }, + }, }, { - name: "vars client-id + secrets private-key - only vars checked", + name: "vars client-id + secrets private-key", appID: "${{ vars.APP_CLIENT_ID }}", privateKey: "${{ secrets.APP_PRIVATE_KEY }}", - expected: "${{ vars.APP_CLIENT_ID != '' }}", + expected: ignoreIfMissingGuard{ + Condition: "${{ vars.APP_CLIENT_ID != '' && env.GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY != '' }}", + EnvAssignments: []stepEnvAssignment{ + {Name: ignoreIfMissingPrivateKeyEnvVar, Value: "${{ secrets.APP_PRIVATE_KEY }}"}, + }, + }, }, { name: "both vars", appID: "${{ vars.APP_CLIENT_ID }}", privateKey: "${{ vars.APP_PRIVATE_KEY }}", - expected: "${{ vars.APP_CLIENT_ID != '' && vars.APP_PRIVATE_KEY != '' }}", + expected: ignoreIfMissingGuard{ + Condition: "${{ vars.APP_CLIENT_ID != '' && vars.APP_PRIVATE_KEY != '' }}", + }, }, { name: "literal values", appID: " id value ", privateKey: "key'value", - expected: "${{ 'id value' != '' && 'key''value' != '' }}", + expected: ignoreIfMissingGuard{ + Condition: "${{ 'id value' != '' && 'key''value' != '' }}", + }, + }, + { + name: "matrix remains valid in step if", + appID: "${{ matrix.app_id }}", + privateKey: "${{ matrix.key }}", + expected: ignoreIfMissingGuard{ + Condition: "${{ matrix.app_id != '' && matrix.key != '' }}", + }, + }, + { + name: "jobs context with bracket syntax routes through env aliases", + appID: "${{ jobs.build.outputs['app-id'] }}", + privateKey: "${{ jobs.build.outputs['private-key'] }}", + expected: ignoreIfMissingGuard{ + Condition: "${{ env.GH_AW_IGNORE_IF_MISSING_APP_ID != '' && env.GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY != '' }}", + EnvAssignments: []stepEnvAssignment{ + {Name: ignoreIfMissingAppIDEnvVar, Value: "${{ jobs.build.outputs['app-id'] }}"}, + {Name: ignoreIfMissingPrivateKeyEnvVar, Value: "${{ jobs.build.outputs['private-key'] }}"}, + }, + }, + }, + { + name: "composite expression with secrets routes through env alias", + appID: "${{ vars.APP_ID || secrets.FALLBACK_ID }}", + privateKey: "${{ vars.APP_KEY }}", + expected: ignoreIfMissingGuard{ + Condition: "${{ env.GH_AW_IGNORE_IF_MISSING_APP_ID != '' && vars.APP_KEY != '' }}", + EnvAssignments: []stepEnvAssignment{ + {Name: ignoreIfMissingAppIDEnvVar, Value: "${{ vars.APP_ID || secrets.FALLBACK_ID }}"}, + }, + }, }, } diff --git a/pkg/workflow/skip_if_match_test.go b/pkg/workflow/skip_if_match_test.go index a5b1420ab69..79c5d6fe336 100644 --- a/pkg/workflow/skip_if_match_test.go +++ b/pkg/workflow/skip_if_match_test.go @@ -485,16 +485,19 @@ engine: claude } lockContentStr := string(lockContent) - // Both credentials use secrets.* which is not valid in if: expressions — - // no if: guard should be emitted on the token mint step. + // Both credentials use secrets.* so ignore-if-missing should guard on + // step-local env aliases instead of secrets.* directly. if strings.Contains(lockContentStr, "if: ${{ secrets.") { t.Error("Expected no guard using secrets context: secrets is not valid in if: expressions") } - if strings.Contains(lockContentStr, "GH_AW_APP_CLIENT_ID:") { - t.Error("Did not expect step-local GH_AW_APP_CLIENT_ID env in mint step guard") + if !strings.Contains(lockContentStr, "GH_AW_IGNORE_IF_MISSING_APP_ID: ${{ secrets.WORKFLOW_APP_ID }}") { + t.Error("Expected step-local env alias for app ID in mint step guard") } - if strings.Contains(lockContentStr, "GH_AW_APP_PRIVATE_KEY:") { - t.Error("Did not expect step-local GH_AW_APP_PRIVATE_KEY env in mint step guard") + if !strings.Contains(lockContentStr, "GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }}") { + t.Error("Expected step-local env alias for private key in mint step guard") + } + if !strings.Contains(lockContentStr, "if: ${{ env.GH_AW_IGNORE_IF_MISSING_APP_ID != '' && env.GH_AW_IGNORE_IF_MISSING_PRIVATE_KEY != '' }}") { + t.Error("Expected skip-if-match app token mint step to use env-based ignore-if-missing guard") } if !strings.Contains(lockContentStr, "github-token: ${{ steps.pre-activation-app-token.outputs.token || secrets.GITHUB_TOKEN }}") { t.Error("Expected skip-if-match to fall back to GITHUB_TOKEN when app minting is skipped") From 826892cc41581110692351a9b94927e8dcf4e12e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:13:51 +0000 Subject: [PATCH 5/7] refactor: harden ignore-if-missing context scanner Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/safe_outputs_app_config.go | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index b38810d991a..509b3f8cf66 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -172,29 +172,37 @@ func isGitHubExpressionIdentifierChar(ch byte) bool { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' } +func isGitHubExpressionIdentifierStart(inner string, i int) bool { + return isGitHubExpressionIdentifierChar(inner[i]) && (i == 0 || !isGitHubExpressionIdentifierChar(inner[i-1])) +} + +func consumeSingleQuotedGitHubExpressionString(inner string, start int) int { + i := start + 1 + for i < len(inner) { + if inner[i] != '\'' { + i++ + continue + } + if i+1 < len(inner) && inner[i+1] == '\'' { + i += 2 + continue + } + return i + 1 + } + return i +} + // containsInvalidIfContextReference returns true when the inner expression body // contains a jobs or secrets context token anywhere outside single-quoted string // literals, including bracket notation such as secrets['TOKEN']. func containsInvalidIfContextReference(inner string) bool { for i := 0; i < len(inner); { if inner[i] == '\'' { - i++ - for i < len(inner) { - if inner[i] != '\'' { - i++ - continue - } - if i+1 < len(inner) && inner[i+1] == '\'' { - i += 2 - continue - } - i++ - break - } + i = consumeSingleQuotedGitHubExpressionString(inner, i) continue } - if !isGitHubExpressionIdentifierChar(inner[i]) || (i > 0 && isGitHubExpressionIdentifierChar(inner[i-1])) { + if !isGitHubExpressionIdentifierStart(inner, i) { i++ continue } From 37f812bf2dc0106b0f6ae67b1fa5743c5ee8c768 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:19:09 +0000 Subject: [PATCH 6/7] docs: clarify ignore-if-missing expression helpers Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/safe_outputs_app_config.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 509b3f8cf66..fd065fe3790 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -173,6 +173,9 @@ func isGitHubExpressionIdentifierChar(ch byte) bool { } func isGitHubExpressionIdentifierStart(inner string, i int) bool { + if i >= len(inner) { + return false + } return isGitHubExpressionIdentifierChar(inner[i]) && (i == 0 || !isGitHubExpressionIdentifierChar(inner[i-1])) } @@ -227,6 +230,8 @@ func containsInvalidIfContextReference(inner string) bool { return false } +// combineGitHubIfExpressions accepts either wrapped `${{ ... }}` conditions or raw +// inner expression fragments and normalizes them into one wrapped if-expression. func combineGitHubIfExpressions(expressions ...string) string { var parts []string for _, expression := range expressions { From 7a5bc2a2c3ce8cf4754f79f03ef4832d7a2217fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:24:31 +0000 Subject: [PATCH 7/7] docs: document ignore-if-missing scanner helpers Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/safe_outputs_app_config.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index fd065fe3790..aafcb1da2fe 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -168,10 +168,14 @@ type ignoreIfMissingGuard struct { EnvAssignments []stepEnvAssignment } +// isGitHubExpressionIdentifierChar reports whether a byte can appear in a GitHub +// Actions expression identifier token (ASCII letter, digit, or underscore). func isGitHubExpressionIdentifierChar(ch byte) bool { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' } +// isGitHubExpressionIdentifierStart reports whether inner[i] begins an identifier +// token rather than landing in the middle of one. func isGitHubExpressionIdentifierStart(inner string, i int) bool { if i >= len(inner) { return false @@ -179,6 +183,9 @@ func isGitHubExpressionIdentifierStart(inner string, i int) bool { return isGitHubExpressionIdentifierChar(inner[i]) && (i == 0 || !isGitHubExpressionIdentifierChar(inner[i-1])) } +// consumeSingleQuotedGitHubExpressionString skips over a single-quoted GitHub +// expression string literal, honoring doubled single quotes as escapes. It returns +// the first byte position after the closing quote, or len(inner) if unterminated. func consumeSingleQuotedGitHubExpressionString(inner string, start int) int { i := start + 1 for i < len(inner) {