Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions .github/workflows/smoke-workflow-call-with-inputs.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .github/workflows/smoke-workflow-call.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions actions/setup/js/resolve_host_repo.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ async function main() {
core.info(`Same-repo invocation: checking out ${targetRepo} @ ${targetRef}`);
}

// Compute the repository name (without owner prefix) for use cases that require
// only the repo name, such as actions/create-github-app-token which expects
// `repositories` to contain repo names only when `owner` is also provided.
const targetRepoName = targetRepo.includes("/") ? targetRepo.substring(targetRepo.indexOf("/") + 1) : targetRepo;

core.setOutput("target_repo", targetRepo);
core.setOutput("target_repo_name", targetRepoName);
core.setOutput("target_ref", targetRef);
}

Expand Down
27 changes: 27 additions & 0 deletions actions/setup/js/resolve_host_repo.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,33 @@ describe("resolve_host_repo.cjs", () => {
expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "abc123def456");
});

it("should output target_repo_name when invoked cross-repo", async () => {
process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "my-org/app-repo";

await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo");
});

it("should output target_repo_name when same-repo invocation", async () => {
process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "my-org/platform-repo";

await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo");
});

it("should output target_repo_name without owner prefix when falling back to GITHUB_REPOSITORY", async () => {
process.env.GITHUB_WORKFLOW_REF = "";
process.env.GITHUB_REPOSITORY = "my-org/fallback-repo";

await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "fallback-repo");
});

it("should include target_ref in step summary for cross-repo invocations", async () => {
process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/feature-branch";
process.env.GITHUB_REPOSITORY = "my-org/app-repo";
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// cross-repo workflow_call scenarios, especially when pinned to a non-default branch).
if hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}"
outputs["target_repo_name"] = "${{ steps.resolve-host-repo.outputs.target_repo_name }}"
outputs["target_ref"] = "${{ steps.resolve-host-repo.outputs.target_ref }}"
}

Expand Down
72 changes: 72 additions & 0 deletions pkg/workflow/compiler_activation_job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,78 @@ func TestActivationJobTargetRefOutput(t *testing.T) {
}
}

// TestActivationJobTargetRepoNameOutput verifies that the activation job exposes target_repo_name
// as an output when a workflow_call trigger is present (without inlined imports). This repo-name-only
// output is required for actions/create-github-app-token which expects repo names without the
// owner prefix when `owner` is also set.
func TestActivationJobTargetRepoNameOutput(t *testing.T) {
tests := []struct {
name string
onSection string
inlinedImports bool
expectTargetRepoName bool
}{
{
name: "workflow_call trigger - target_repo_name output added",
onSection: `"on":
workflow_call:`,
expectTargetRepoName: true,
},
{
name: "mixed triggers with workflow_call - target_repo_name output added",
onSection: `"on":
issue_comment:
types: [created]
workflow_call:`,
expectTargetRepoName: true,
},
{
name: "workflow_call with inlined-imports - no target_repo_name output",
onSection: `"on":
workflow_call:`,
inlinedImports: true,
expectTargetRepoName: false,
},
{
name: "no workflow_call - no target_repo_name output",
onSection: `"on":
issues:
types: [opened]`,
expectTargetRepoName: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := NewCompilerWithVersion("dev")
compiler.SetActionMode(ActionModeDev)

data := &WorkflowData{
Name: "test-workflow",
On: tt.onSection,
InlinedImports: tt.inlinedImports,
AI: "copilot",
}

job, err := compiler.buildActivationJob(data, false, "", "test.lock.yml")
require.NoError(t, err, "buildActivationJob should succeed")
require.NotNil(t, job, "activation job should not be nil")

if tt.expectTargetRepoName {
assert.Contains(t, job.Outputs, "target_repo_name",
"activation job should expose target_repo_name output for GitHub App token minting")
assert.Equal(t,
"${{ steps.resolve-host-repo.outputs.target_repo_name }}",
job.Outputs["target_repo_name"],
"target_repo_name output should reference resolve-host-repo step")
} else {
assert.NotContains(t, job.Outputs, "target_repo_name",
"activation job should not expose target_repo_name when workflow_call is absent or inlined-imports enabled")
}
})
}
}

// TestCheckoutGitHubFolderIncludesRef verifies that the activation checkout emits a ref: field
// when a workflow_call trigger is present. This ensures caller-hosted relays pinned to a
// feature branch check out the correct platform branch during activation.
Expand Down
7 changes: 4 additions & 3 deletions pkg/workflow/compiler_safe_outputs_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,12 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa

// Add GitHub App token minting step at the beginning if app is configured
if data.SafeOutputs.GitHubApp != nil {
// For workflow_call relay workflows, scope the token to the platform repo so that
// API calls targeting the host repo (e.g. dispatch_workflow) are authorized.
// For workflow_call relay workflows, scope the token to the platform repo name only
// (not the full slug) because actions/create-github-app-token expects repo names
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}"
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
}
appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo)
// Calculate insertion index: after setup action (if present) and artifact downloads, but before checkout and safe output steps
Expand Down
75 changes: 75 additions & 0 deletions pkg/workflow/compiler_safe_outputs_job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,78 @@ func TestGitHubAppWithPushToPRBranch(t *testing.T) {
// Verify step ID is set correctly
assert.Contains(t, stepsContent, "id: safe-outputs-app-token")
}

// TestJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback is a regression test verifying that
// a safe-output job compiled for a workflow_call trigger uses
// needs.activation.outputs.target_repo_name (repo name only, no owner prefix) as the repositories
// fallback for the GitHub App token mint step, instead of the full target_repo slug.
// This prevents actions/create-github-app-token from receiving an invalid owner/repo slug
// in the repositories field when owner is also set.
func TestJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback(t *testing.T) {
compiler := NewCompiler()
compiler.jobManager = NewJobManager()

workflowData := &WorkflowData{
Name: "Test Workflow",
On: `"on":
workflow_call:`,
SafeOutputs: &SafeOutputsConfig{
GitHubApp: &GitHubAppConfig{
AppID: "${{ vars.APP_ID }}",
PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
},
CreateIssues: &CreateIssuesConfig{
TitlePrefix: "[Test] ",
},
},
}

job, _, err := compiler.buildConsolidatedSafeOutputsJob(workflowData, string(constants.AgentJobName), "test.md")

require.NoError(t, err, "Should successfully build job")
require.NotNil(t, job, "Job should not be nil")

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 }}",
"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")
}

// TestConclusionJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback is a regression test
// verifying that the conclusion job compiled for a workflow_call trigger uses
// needs.activation.outputs.target_repo_name (repo name only) as the repositories fallback
// for the GitHub App token mint step.
func TestConclusionJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback(t *testing.T) {
compiler := NewCompiler()
compiler.jobManager = NewJobManager()
compiler.SetActionMode(ActionModeDev)

workflowData := &WorkflowData{
Name: "Test Workflow",
On: `"on":
workflow_call:`,
SafeOutputs: &SafeOutputsConfig{
GitHubApp: &GitHubAppConfig{
AppID: "${{ vars.APP_ID }}",
PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
},
AddComments: &AddCommentsConfig{},
},
}

job, err := compiler.buildConclusionJob(workflowData, string(constants.AgentJobName), nil)

require.NoError(t, err, "Should successfully build conclusion job")
require.NotNil(t, job, "Conclusion job should not be nil")

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 }}",
"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")
}
6 changes: 4 additions & 2 deletions pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa
if data.SafeOutputs.GitHubApp != nil {
// Compute permissions based on configured safe outputs (principle of least privilege)
permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs)
// For workflow_call relay workflows, scope the token to the platform repo.
// For workflow_call relay workflows, scope the token to the platform repo name only
// (not the full slug) because actions/create-github-app-token expects repo names
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}"
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
}
steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo)...)
}
Expand Down
9 changes: 5 additions & 4 deletions pkg/workflow/safe_outputs_app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ func (c *Compiler) mergeAppFromIncludedConfigs(topSafeOutputs *SafeOutputsConfig
// buildGitHubAppTokenMintStep generates the step to mint a GitHub App installation access token
// Permissions are automatically computed from the safe output job requirements.
// fallbackRepoExpr overrides the default ${{ github.event.repository.name }} fallback when
// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo for
// workflow_call relay workflows so the token is scoped to the platform repo, not the caller's).
// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo_name for
// workflow_call relay workflows so the token is scoped to the platform repo's NAME, not the full
// owner/repo slug — actions/create-github-app-token expects repo names only when owner is also set).
func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string {
safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories))
var steps []string
Expand Down Expand Up @@ -161,8 +162,8 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions
}
} else {
// No explicit repositories: use fallback expression, or default to the triggering repo's name.
// For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo so
// the token is scoped to the platform (host) repo rather than the caller repo.
// For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo_name so
// the token is scoped to the platform (host) repo name rather than the full owner/repo slug.
repoExpr := fallbackRepoExpr
if repoExpr == "" {
repoExpr = "${{ github.event.repository.name }}"
Expand Down
6 changes: 4 additions & 2 deletions pkg/workflow/safe_outputs_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo
// Add GitHub App token minting step if app is configured
if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil {
safeOutputsJobsLog.Print("Adding GitHub App token minting step with auto-computed permissions")
// For workflow_call relay workflows, scope the token to the platform repo.
// For workflow_call relay workflows, scope the token to the platform repo name only
// (not the full slug) because actions/create-github-app-token expects repo names
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}"
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
}
steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions, appTokenFallbackRepo)...)
}
Expand Down
Loading