Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix workflow_call masking resilience for artifact and target repo out…
…puts

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Jun 17, 2026
commit 0dc640c688bf68d7a2b5c8d57e88dd4e79659f32
7 changes: 5 additions & 2 deletions pkg/workflow/compiler_safe_outputs_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa
threatDetectionEnabled := IsDetectionJobEnabled(data.SafeOutputs)

// Compute artifact prefix once; it is referenced in all three phases.
agentArtifactPrefix := artifactPrefixExprForDownstreamJob(data)
agentArtifactPrefix := artifactPrefixExprForCurrentJob(data)

// Phase 1: Setup action, artifact downloads, and user-provided steps
setupSteps, err := c.buildSafeOutputsSetupAndDownloadSteps(data, agentArtifactPrefix)
Expand Down Expand Up @@ -109,6 +109,9 @@ func (c *Compiler) buildSafeOutputsSetupAndDownloadSteps(data *WorkflowData, age

// Add artifact download steps after setup.
// In workflow_call context, use the per-invocation prefix to avoid artifact name clashes.
if hasWorkflowCallTrigger(data.On) {
steps = append(steps, generateArtifactPrefixStep()...)
}
steps = append(steps, buildAgentOutputDownloadSteps(agentArtifactPrefix, c.getActionPin)...)

// Add patch artifact download if create-pull-request or push-to-pull-request-branch is enabled
Expand Down Expand Up @@ -434,7 +437,7 @@ func (c *Compiler) buildSafeOutputsJobFromParts(
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
appTokenFallbackRepo = targetRepoNameExprForWorkflowCall()
}
appTokenSteps := c.buildGitHubAppTokenMintStepForRepository(
data.SafeOutputs.GitHubApp,
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/compiler_safe_outputs_job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1161,7 +1161,7 @@ func TestJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback(t *testing.T) {
stepsContent := strings.Join(job.Steps, "")

// Must use the repo-name-only output, NOT the full slug
assert.Contains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo_name }}",
assert.Contains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo_name || github.event.repository.name }}",
"GitHub App token step must use target_repo_name (repo name only) for workflow_call workflows")
assert.NotContains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo }}",
"GitHub App token step must not use target_repo (full slug) for workflow_call workflows")
Expand Down Expand Up @@ -1197,7 +1197,7 @@ func TestConclusionJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback(t *tes
stepsContent := strings.Join(job.Steps, "")

// Must use the repo-name-only output, NOT the full slug
assert.Contains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo_name }}",
assert.Contains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo_name || github.event.repository.name }}",
"Conclusion job GitHub App token step must use target_repo_name (repo name only) for workflow_call workflows")
assert.NotContains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo }}",
"Conclusion job GitHub App token step must not use target_repo (full slug) for workflow_call workflows")
Expand Down
17 changes: 17 additions & 0 deletions pkg/workflow/compiler_workflow_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ func artifactPrefixExprForActivationJob(data *WorkflowData) string {
return "${{ steps.artifact-prefix.outputs.prefix }}"
}

// artifactPrefixExprForCurrentJob returns the GitHub Actions expression for the artifact prefix
// when the current job computes the prefix locally via generateArtifactPrefixStep.
func artifactPrefixExprForCurrentJob(data *WorkflowData) string {
if !hasWorkflowCallTrigger(data.On) {
return ""
}
return "${{ steps.artifact-prefix.outputs.prefix }}"
}

// artifactPrefixExprForDownstreamJob returns the GitHub Actions expression for the artifact
// prefix used in jobs that depend on the activation job (references an activation job output).
// Returns empty string for non-workflow_call workflows.
Expand All @@ -82,6 +91,14 @@ func artifactPrefixExprForAgentDownstreamJob(data *WorkflowData) string {
return "${{ needs.agent.outputs.artifact_prefix }}"
}

func targetRepoExprForWorkflowCall() string {
return "${{ needs.activation.outputs.target_repo || github.repository }}"
}

func targetRepoNameExprForWorkflowCall() string {
return "${{ needs.activation.outputs.target_repo_name || github.event.repository.name }}"
}

// injectWorkflowCallOutputs adds on.workflow_call.outputs declarations for safe-output results
// when the workflow uses workflow_call as a trigger.
//
Expand Down
11 changes: 8 additions & 3 deletions pkg/workflow/compiler_yaml_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func (c *Compiler) generateInitialAndCheckoutSteps(yaml *strings.Builder, data *
// for .github/.agents sparse checkouts when called cross-repo.
// The activation job exposes this as needs.activation.outputs.target_repo.
if hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
checkoutMgr.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}")
checkoutMgr.SetCrossRepoTargetRepo(targetRepoExprForWorkflowCall())
}

// Mint checkout app tokens directly in the agent job before checkout steps are executed.
Expand Down Expand Up @@ -389,7 +389,12 @@ func (c *Compiler) generateEngineInstallAndPreAgentSteps(yaml *strings.Builder,
// This must happen BEFORE pre-agent-steps so the base-branch snapshot
// (saved in /tmp/gh-aw/base/ inside the artifact) is available for the restore step below.
compilerYamlLog.Print("Adding activation artifact download step")
activationArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.ActivationArtifactName
if hasWorkflowCallTrigger(data.On) {
for _, step := range generateArtifactPrefixStep() {
yaml.WriteString(step)
}
}
activationArtifactName := artifactPrefixExprForCurrentJob(data) + constants.ActivationArtifactName
yaml.WriteString(" - name: Download activation artifact\n")
fmt.Fprintf(yaml, " uses: %s\n", c.getActionPin("actions/download-artifact"))
yaml.WriteString(" with:\n")
Expand Down Expand Up @@ -779,7 +784,7 @@ func (c *Compiler) generatePostAgentCollectionAndUpload(yaml *strings.Builder, d

// Generate single unified artifact upload with all collected paths.
// In workflow_call context, apply the per-invocation prefix to avoid name clashes.
agentArtifactPrefix := artifactPrefixExprForDownstreamJob(data)
agentArtifactPrefix := artifactPrefixExprForCurrentJob(data)
c.generateUnifiedArtifactUpload(yaml, artifactPaths, agentArtifactPrefix)

// In dev mode the setup action is referenced via a local path (./actions/setup), so its files
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/github_app_owner_derivation.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func inferSingleCheckoutRepositoryForGitHubAppOwner(data *WorkflowData) string {

if repository == "" && hasWorkflowCallTrigger(data.On) {
githubAppOwnerDerivationLog.Print("No explicit checkout.repository found for workflow_call; using needs.activation.outputs.target_repo as owner source")
return "${{ needs.activation.outputs.target_repo }}"
return targetRepoExprForWorkflowCall()
}

if repository == "" {
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/github_app_owner_derivation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func TestInferSingleCheckoutRepositoryForGitHubAppOwner(t *testing.T) {
{Path: "default"},
},
}
assert.Equal(t, "${{ needs.activation.outputs.target_repo }}", inferSingleCheckoutRepositoryForGitHubAppOwner(data), "workflow_call should infer activation target repository when no explicit checkout repository is set")
assert.Equal(t, "${{ needs.activation.outputs.target_repo || github.repository }}", inferSingleCheckoutRepositoryForGitHubAppOwner(data), "workflow_call should infer activation target repository when no explicit checkout repository is set")
})
}

Expand Down Expand Up @@ -273,5 +273,5 @@ func TestWorkflowCallSafeOutputsAppTokenUsesRepositoryOwnerSource(t *testing.T)
assert.Contains(t, safeOutputsSteps, "id: safe-outputs-app-token-owner", "safe outputs app token should include owner helper for expression repository")
assert.Contains(t, safeOutputsSteps, "owner: ${{ steps.safe-outputs-app-token-owner.outputs.owner }}", "safe outputs app token should consume derived owner output")
assert.Contains(t, safeOutputsSteps, "GH_AW_TARGET_REPOSITORY: ${{ inputs.target_repository }}", "safe outputs owner helper should target explicit checkout repository expression")
assert.Contains(t, safeOutputsSteps, "repositories: ${{ needs.activation.outputs.target_repo_name }}", "workflow_call safe outputs app token should default repositories to target_repo_name")
assert.Contains(t, safeOutputsSteps, "repositories: ${{ needs.activation.outputs.target_repo_name || github.event.repository.name }}", "workflow_call safe outputs app token should default repositories to target_repo_name")
}
9 changes: 6 additions & 3 deletions pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
appTokenFallbackRepo = targetRepoNameExprForWorkflowCall()
}
steps = append(steps, c.buildGitHubAppTokenMintStepForRepository(
data.SafeOutputs.GitHubApp,
Expand All @@ -72,10 +72,13 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa

// Add artifact download steps once (shared by noop and conclusion steps).
// In workflow_call context, use the per-invocation prefix to avoid artifact name clashes.
steps = append(steps, buildAgentOutputDownloadSteps(artifactPrefixExprForDownstreamJob(data), c.getActionPin)...)
if hasWorkflowCallTrigger(data.On) {
steps = append(steps, generateArtifactPrefixStep()...)
}
steps = append(steps, buildAgentOutputDownloadSteps(artifactPrefixExprForCurrentJob(data), c.getActionPin)...)
// Package a compact usage artifact so forecasting/analytics commands can fetch
// token usage and aw_info without downloading full agent artifacts.
steps = append(steps, buildUsageArtifactUploadSteps(artifactPrefixExprForDownstreamJob(data), c.getActionPin)...)
steps = append(steps, buildUsageArtifactUploadSteps(artifactPrefixExprForCurrentJob(data), c.getActionPin)...)
// Write this run's AIC to the per-workflow usage cache and save it via actions/cache
// so the next activation can skip artifact downloads for known runs.
if hasMaxDailyAICGuardrail(data) && data.WorkflowID != "" {
Expand Down
11 changes: 7 additions & 4 deletions pkg/workflow/notify_comment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1109,16 +1109,19 @@ func TestConclusionJobWorkflowCallArtifactPrefix(t *testing.T) {

allSteps := strings.Join(job.Steps, "\n")

// In workflow_call context, the artifact download must use the prefixed name
// to match the upload step which uses needs.activation.outputs.artifact_prefix.
prefixedArtifactName := "${{ needs.activation.outputs.artifact_prefix }}agent"
// In workflow_call context, the conclusion job computes the artifact prefix locally
// and uses it for both download and usage artifact naming.
prefixedArtifactName := "${{ steps.artifact-prefix.outputs.prefix }}agent"
if !strings.Contains(allSteps, prefixedArtifactName) {
t.Errorf("Expected conclusion job download step to use prefixed artifact name %q in workflow_call context, but it was not found.\nGenerated steps:\n%s", prefixedArtifactName, allSteps)
}
prefixedUsageArtifactName := "${{ needs.activation.outputs.artifact_prefix }}usage"
prefixedUsageArtifactName := "${{ steps.artifact-prefix.outputs.prefix }}usage"
if !strings.Contains(allSteps, prefixedUsageArtifactName) {
t.Errorf("Expected conclusion job usage artifact upload to use prefixed artifact name %q in workflow_call context, but it was not found.\nGenerated steps:\n%s", prefixedUsageArtifactName, allSteps)
}
if !strings.Contains(allSteps, "id: artifact-prefix") {
t.Errorf("Expected conclusion job to compute artifact prefix locally in workflow_call context.\nGenerated steps:\n%s", allSteps)
}

// Ensure the unprefixed artifact name is not used
if strings.Contains(allSteps, "name: agent\n") {
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow
safeOutputs := data.SafeOutputs
if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil {
if safeOutputs.DispatchWorkflow.TargetRepoSlug == "" {
safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}")
safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, targetRepoExprForWorkflowCall())
safeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay")
}
if safeOutputs.DispatchWorkflow.TargetRef == "" {
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/safe_outputs_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
appTokenFallbackRepo = targetRepoNameExprForWorkflowCall()
}
steps = append(steps, c.buildGitHubAppTokenMintStepForRepository(
data.SafeOutputs.GitHubApp,
Expand Down
5 changes: 4 additions & 1 deletion pkg/workflow/threat_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,10 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) {

// Download agent output artifact to access output files (prompt.txt, agent_output.json, patches).
// Use agent-downstream prefix since this job depends on the agent job.
agentArtifactPrefix := artifactPrefixExprForAgentDownstreamJob(data)
if hasWorkflowCallTrigger(data.On) {
steps = append(steps, generateArtifactPrefixStep()...)
}
agentArtifactPrefix := artifactPrefixExprForCurrentJob(data)
steps = append(steps, buildAgentOutputDownloadSteps(agentArtifactPrefix, c.getActionPin)...)

// Download experiment artifact so the detection agent can read the current variant assignments.
Expand Down