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
55 changes: 55 additions & 0 deletions e2e/scenarios/21-cross-repo-callback.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: "Cross-Repo Reusable-Workflow Callback"
description: |
Verifies that a build callback whose workflow: points at a reusable workflow
in another repository (org/repo/.github/workflows/file.yaml@ref) is generated
without attempting a local read of the unreachable @ref path, and that the
generated orchestrate.yaml wires the cross-repo caller job with the standard
callback-contract inputs plus the artifact_id output.

Generator-output verification only.

The harness intentionally does NOT seed a local stub for the cross-repo
callback (normalizeCallbackStubPath skips @ref paths), so generation runs
against the same conditions the live fleet hits: no local file for the
external workflow. A pre-fix generator aborts here reading the @ref path; the
fixed generator emits the caller job from the callback contract instead.

config:
trunk_branch: main
environments: [staging, prod]
builds:
- name: app
workflow: build-app.yaml
triggers: ["src/**"]
- name: sharedlib
workflow: stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy-app.yaml
triggers: ["src/**"]

steps:
- name: "Initial commit; assert cross-repo caller job in orchestrate.yaml"
action: commit
commit:
message: "feat: add app and cross-repo sharedlib build"
files:
src/app.go: |
package main
func main() {}
expect:
workflow_files:
- path: ".github/workflows/orchestrate.yaml"
contains:
# The cross-repo build is wired as a uses: caller to the external
# reusable workflow, verbatim with its @ref.
- "uses: stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main"
# The caller carries the standard callback-contract with: inputs.
- "environment: ${{ github.event.inputs.environment || 'staging' }}"
- "sha: ${{ needs.setup.outputs.head_sha }}"
# The cross-repo build's artifact_id output flows downstream.
- "needs.build-sharedlib.outputs.artifact_id"
not_contains:
# The literal @ref must never leak into a local-path read or job id.
- "build-shared.yaml@main:"
88 changes: 88 additions & 0 deletions internal/generate/cross_repo_callback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package generate

import (
"testing"

"github.com/stablekernel/cascade/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCrossRepoBuildCallback_GeneratesWithoutLocalRead reproduces the
// stablekernel/cascade-example-primary case: a build callback whose workflow:
// points at a reusable workflow in another repository
// (org/repo/.github/workflows/file.yaml@ref). The generator must NOT try to
// read that workflow from local disk (it does not exist locally and the path
// ends in a literal "@ref"), and it must still emit a correct caller job with a
// with: block carrying the standard callback-contract inputs.
func TestCrossRepoBuildCallback_GeneratesWithoutLocalRead(t *testing.T) {
// baseDir contains the LOCAL build callback only. The cross-repo callback
// has no local file on purpose: a pre-fix generator hard-fails reading it.
baseDir := t.TempDir()
createMockWorkflow(t, baseDir, ".github/workflows/build-app.yaml")

const crossRepoWorkflow = "stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main"

cfg := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"staging", "prod"},
Builds: []config.BuildConfig{
{Name: "app", Workflow: ".github/workflows/build-app.yaml"},
{Name: "sharedlib", Workflow: crossRepoWorkflow},
},
}

gen := NewGenerator(cfg, baseDir)
content, err := gen.Generate()

// (a) Generation must succeed: no local-read error for the @ref workflow.
require.NoError(t, err, "cross-repo callback must not trigger a local file read")

// The cross-repo caller job must reference the external workflow verbatim.
assert.Contains(t, content, "uses: "+crossRepoWorkflow,
"cross-repo callback must be wired as a uses: caller to the external workflow")

// (b) The cross-repo caller job must carry a with: block with the standard
// contract inputs (environment + sha), matching the live fleet output.
job := extractJobBlock(content, "build-sharedlib")
require.NotEmpty(t, job, "build-sharedlib job not found in generated content")
assert.Contains(t, job, "with:",
"cross-repo caller job must emit a with: block")
assert.Contains(t, job, "environment: ${{ github.event.inputs.environment || 'staging' }}",
"cross-repo caller must pass the environment contract input")
assert.Contains(t, job, "sha: ${{ needs.setup.outputs.head_sha }}",
"cross-repo caller must pass the sha contract input")

// (c) The cross-repo build's artifact_id output must still flow downstream
// (state capture / finalize), matching the live fleet's committed output.
assert.Contains(t, content, "needs.build-sharedlib.outputs.artifact_id",
"cross-repo build's artifact_id output must be wired downstream")
}

// TestCrossRepoCallback_OperatorInputsPassThrough verifies that operator-declared
// manifest inputs on a cross-repo callback are still emitted in the caller's
// with: block, even though the external workflow cannot be parsed locally.
func TestCrossRepoCallback_OperatorInputsPassThrough(t *testing.T) {
baseDir := t.TempDir()

cfg := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"staging"},
Builds: []config.BuildConfig{
{
Name: "sharedlib",
Workflow: "stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main",
Inputs: map[string]interface{}{"version": "v1.2.3"},
},
},
}

gen := NewGenerator(cfg, baseDir)
content, err := gen.Generate()
require.NoError(t, err)

job := extractJobBlock(content, "build-sharedlib")
require.NotEmpty(t, job, "build-sharedlib job not found")
assert.Contains(t, job, "version: v1.2.3",
"operator-declared manifest input must be passed to the cross-repo caller")
}
64 changes: 61 additions & 3 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ func (g *Generator) discoverOutputsAndInputs() error {
allCallbacks := []struct {
jobID string
name string
cbType string
workflow string
inputs map[string]interface{}
}{}
Expand All @@ -457,30 +458,48 @@ func (g *Generator) discoverOutputsAndInputs() error {
allCallbacks = append(allCallbacks, struct {
jobID string
name string
cbType string
workflow string
inputs map[string]interface{}
}{"validate", "validate", g.config.Validate.Workflow, g.config.Validate.Inputs})
}{"validate", "validate", config.CallbackTypeValidate, g.config.Validate.Workflow, g.config.Validate.Inputs})
}
for _, b := range g.config.Builds {
jobID := config.JobID(config.CallbackTypeBuild, b.Name)
allCallbacks = append(allCallbacks, struct {
jobID string
name string
cbType string
workflow string
inputs map[string]interface{}
}{jobID, b.Name, b.Workflow, b.Inputs})
}{jobID, b.Name, config.CallbackTypeBuild, b.Workflow, b.Inputs})
}
for _, d := range g.config.Deploys {
jobID := config.JobID(config.CallbackTypeDeploy, d.Name)
allCallbacks = append(allCallbacks, struct {
jobID string
name string
cbType string
workflow string
inputs map[string]interface{}
}{jobID, d.Name, d.Workflow, d.Inputs})
}{jobID, d.Name, config.CallbackTypeDeploy, d.Workflow, d.Inputs})
}

for _, cb := range allCallbacks {
// Cross-repo callbacks reference a reusable workflow in another
// repository (org/repo/.github/workflows/file.yaml@ref). That file does
// not exist on local disk, so we cannot parse it to discover its inputs
// and outputs. Seed the callback's contract surface instead of doing a
// local read that would always fail on the literal "@ref" path.
if config.IsExternalWorkflow(cb.workflow) {
g.inputs[cb.jobID] = crossRepoInputs(cb.cbType, cb.inputs)
g.outputs[cb.jobID] = crossRepoOutputs(cb.cbType)
// The framework always provides environment/sha/dry_run, so a
// cross-repo callback has no required input cascade cannot satisfy
// from its own knowledge. Leave required empty rather than guessing.
g.requiredInputs[cb.jobID] = nil
continue
}

// Read the stub from the normalized location so a bare filename
// (build.yaml) resolves to .github/workflows/build.yaml, which is where
// GitHub requires local reusable workflows to live and where the emitted
Expand Down Expand Up @@ -513,6 +532,45 @@ func (g *Generator) discoverOutputsAndInputs() error {
return nil
}

// crossRepoInputs returns the set of input names a cross-repo callback is
// assumed to declare. Because the external workflow is in another repository and
// cannot be parsed locally, this falls back to the callback contract: every
// validate/build/deploy callback declares the standard environment, sha, and
// dry_run inputs, plus any inputs the operator wired explicitly in the manifest
// (inputs:/env_inputs:). Returning these makes writeWithInputs emit the standard
// with: wiring (environment, sha, dry_run when supported) for the caller job, so
// the live cross-repo call receives the contract inputs the external workflow
// expects. Names are de-duplicated; order is not significant to callers.
func crossRepoInputs(callbackType string, operatorInputs map[string]interface{}) []string {
// Standard callback-contract inputs (see docs callback-contract.md).
names := []string{"environment", "sha", "dry_run"}
seen := map[string]struct{}{"environment": {}, "sha": {}, "dry_run": {}}

// Operator-declared manifest inputs are passed through by writeWithInputs
// only when the callback declares them, so surface them here too.
for name := range operatorInputs {
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
_ = callbackType // reserved for future per-type input differences
return names
}

// crossRepoOutputs returns the output names a cross-repo callback is assumed to
// declare. Build callbacks follow the contract's recommended artifact_id output
// (captured to state and forwarded to dependents), which matches what the
// example fleet's external build callbacks expose. Validate and deploy callbacks
// declare no standard outputs, so none are assumed.
func crossRepoOutputs(callbackType string) []string {
if callbackType == config.CallbackTypeBuild {
return []string{"artifact_id"}
}
return nil
}

func (g *Generator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile())
Expand Down
Loading