Skip to content

fix: resolve cross-repo reusable-workflow callbacks without local read - #172

Merged
joshua-temple merged 2 commits into
mainfrom
fix/cross-repo-callback-io
Jun 16, 2026
Merged

joshua-temple merged 2 commits into
mainfrom
fix/cross-repo-callback-io

Conversation

@joshua-temple

@joshua-temple joshua-temple commented Jun 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Generator.discoverOutputsAndInputs read every callback's workflow file from local disk. For a cross-repo callback whose workflow: is org/repo/.github/workflows/file.yaml@ref, normalizeWorkflowPath passes the @ref through unchanged, so filepath.Join produces a bogus local path ending in a literal @ref and os.ReadFile hard-fails, aborting generation. This blocked generating any manifest that calls a reusable workflow in another repository. stablekernel/cascade-example-primary hits this exactly via builds[].sharedlib.workflow: stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main.

Fix

In discoverOutputsAndInputs, detect cross-repo refs with the existing config.IsExternalWorkflow helper and skip the local read for them. Instead of parsing the unreachable file, seed the callback's contract surface:

  • Inputs: the standard contract inputs (environment, sha, dry_run) plus any operator-declared manifest inputs (inputs:/env_inputs:), so writeWithInputs still emits the correct with: wiring.
  • Outputs: build callbacks default to the contract's recommended artifact_id output, so state capture and downstream chaining keep working.
  • Required inputs: none (the framework always provides the standard inputs).

Correctness of the generated with: block

The caller job for the cross-repo callback still carries the full callback-contract with: block. Verified against the live committed orchestrate.yaml in cascade-example-primary: the regenerated build-sharedlib job is byte-identical in uses: and with: (environment + sha), and the artifact_id output wiring (state capture, finalize summary, downstream env) matches as well. The build-shared callback's optional version input is not passed because the primary manifest declares no value for it (the workflow defaults it), matching the committed output; when an operator does declare such an input, it passes through (covered by a test).

Verification

  • go build ./..., go test ./... (1347 pass), go vet ./..., golangci-lint run ./...: all green. go test -race ./internal/generate/: clean. e2e module: go build ./... and go vet ./... clean.
  • Before/after repro against the real fleet manifest:
    • Before: generating from primary's manifest fails with reading workflow stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main: ... no such file or directory.
    • After: generation succeeds and the cross-repo caller job emits uses: stablekernel/cascade-example-artifact-a/.github/workflows/build-shared.yaml@main with with: { environment, sha }, equivalent to the committed orchestrate.yaml.
  • actionlint on the regenerated orchestrate: clean for the cross-repo job (only pre-existing SC2129 style suggestions in cascade-owned finalize scripts, also present in the committed file).

Tests

Generator-level (internal/generate/cross_repo_callback_test.go):

  • TestCrossRepoBuildCallback_GeneratesWithoutLocalRead: feeds a manifest shaped like primary's (local + cross-repo @ref build), asserts generation no longer errors, the caller job uses the external workflow verbatim, the with: block carries the contract inputs, and artifact_id flows downstream. Written first as a failing test reproducing the exact local-read error.
  • TestCrossRepoCallback_OperatorInputsPassThrough: asserts operator-declared manifest inputs still reach the cross-repo caller.

End-to-end (e2e/scenarios/21-cross-repo-callback.yaml):

  • A scenario with a top-level builds[] entry whose workflow: is a cross-repo @ref, asserting the generated orchestrate.yaml contains the uses: ...@ref caller, its environment/sha with: lines, and the downstream needs.build-sharedlib.outputs.artifact_id wiring (with a not_contains guard that the literal @ref never leaks into a job id). The harness intentionally does not seed a local stub for cross-repo refs (normalizeCallbackStubPath skips @ref), so the scenario runs against the same no-local-file conditions the live fleet hits. This uses the harness's static workflow_files assertion mode (as in scenarios 14 and 17) and does not require act to execute the cross-repo call. The scenario was verified to parse and discover via DiscoverMultiStepScenarios; the full act/Docker run is left to CI.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit 3ed5040 into main Jun 16, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant