From 711d19b781df774ed0bd56d798aba8872ee6845e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 15 Jun 2026 22:34:29 -0400 Subject: [PATCH 1/2] fix: resolve cross-repo reusable-workflow callbacks without local read Signed-off-by: Joshua Temple --- internal/generate/cross_repo_callback_test.go | 88 +++++++++++++++++++ internal/generate/generator.go | 64 +++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 internal/generate/cross_repo_callback_test.go diff --git a/internal/generate/cross_repo_callback_test.go b/internal/generate/cross_repo_callback_test.go new file mode 100644 index 00000000..f965e0f5 --- /dev/null +++ b/internal/generate/cross_repo_callback_test.go @@ -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") +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index b1086b78..5000a38b 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -449,6 +449,7 @@ func (g *Generator) discoverOutputsAndInputs() error { allCallbacks := []struct { jobID string name string + cbType string workflow string inputs map[string]interface{} }{} @@ -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 @@ -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()) From ff9d2ee58684aee3d9fd8d1cc9222b030482eca7 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 15 Jun 2026 22:39:01 -0400 Subject: [PATCH 2/2] test: add e2e scenario for cross-repo reusable-workflow callback Signed-off-by: Joshua Temple --- e2e/scenarios/21-cross-repo-callback.yaml | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 e2e/scenarios/21-cross-repo-callback.yaml diff --git a/e2e/scenarios/21-cross-repo-callback.yaml b/e2e/scenarios/21-cross-repo-callback.yaml new file mode 100644 index 00000000..dd1aae14 --- /dev/null +++ b/e2e/scenarios/21-cross-repo-callback.yaml @@ -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:"