Skip to content

fix(generate): include a dependency's retry shims in a dependent's needs - #639

Merged
joshua-temple merged 2 commits into
mainfrom
fix/dependent-deploy-retry-shim-needs
Jul 18, 2026
Merged

fix(generate): include a dependency's retry shims in a dependent's needs#639
joshua-temple merged 2 commits into
mainfrom
fix/dependent-deploy-retry-shim-needs

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

A dependent deploy's if: gate already reads needs.<dep>-retry-N.result to judge its dependency's effective (ladder-wide) result, so a retry-rescued dependency does not skip its dependents. But the dependent's needs: list was built from GetDirectDependencies alone, which returns only the base dependency's job ID, never its retry shims.

GitHub Actions can only resolve a needs.<job> reference for a job actually listed in that job's needs:. The emitted pair silently disagreed:

  • deploy-notify.needs = [setup, deploy-app]
  • deploy-notify.if references needs.deploy-app-retry-1.result

actionlint rejects this at parse (property "deploy-app-retry-1" is not defined in object type ...), and had it somehow been accepted, the reference resolves to an always-empty value at runtime, so a retry-rescued dependency (base fails, a shim succeeds) would leave the dependent silently skipped instead of running.

The finalize job's needs: already listed retry shims correctly; only a dependent deploy'sneeds: was missing them.

Fix

internal/generate/generator.go:

  • Added retryShimJobIDs(jobID, retries) as the single source of truth for a job's shim IDs.
  • writeCallbackJob's needs: construction now appends each hard dependency's retry shims right after the dependency itself.
  • retrySucceededCond (the if: gate builder) and writeFinalizeJob's job collection now both derive from the same helper, so the needs: list and the if: gate can no longer drift apart.
  • A dependency with no retries is unaffected: needs: collapses to the bare dependency job ID, byte-identical to before.

Verification

  • internal/generate/pass10_silent_output_test.go: added TestGM7_DependentDeploy_NeedsIncludesRetryShims (red before the fix: needs: [setup, deploy-web]; green after: needs: [setup, deploy-web, deploy-web-retry-1, deploy-web-retry-2]) and TestGM7_DependentDeploy_NoRetries_NeedsByteIdentical (proves the no-retries path is unchanged).
  • internal/generate/correctness_census_map_test.go: deploys[].depends_on[] now points at TestGM7_DependentDeploy_NeedsIncludesRetryShims.
  • internal/generate/actionlint_feature_matrix_test.go: added the retries_dependent_deploy case, pairing retries with depends_on so this exact combination stays under permanent actionlint guard (the pre-existing retries case never exercised a dependent deploy, which is why the matrix hadn't caught this).
  • e2e/scenarios/73-deploy-retries.yaml: extended with a notify deploy that depends_on the retried web deploy. Asserts the emitted needs: line carries the base dependency plus both retry shims, and that notify correctly skips in the non-rescue path (the scenario's callback always fails, so no rescue occurs). The true rescue-runtime proof (base fails, shim succeeds, dependent runs) is documented as out of reach for this harness for the same structural reason the existing ladder proof already states (no artifact server, no cross-job state channel, identical github.job across shims); the needs:/if: agreement it depends on is instead pinned by the two unit tests above.
  • docs/src/content/docs/internals/coverage-matrix.md: filled in the 73-deploy-retries scenario reference for "Callback retry wrapper", which was blank.
  • go build ./... && go test ./... && go test ./... -race && golangci-lint run ./... all clean on the root module; go build ./... && go vet ./... && go test ./... -short && golangci-lint run ./... clean on the e2e module.

A dependent deploy's if: gate already read needs.<dep>-retry-N.result to
judge its dependency's effective (ladder-wide) result, but its needs: list
was built from GetDirectDependencies alone, which returns only the base
dependency job ID. GitHub Actions can only resolve a needs.<job> reference
for a job actually listed in needs:, so the emitted pair silently
disagreed: actionlint rejects it at parse, and had it been accepted, a
retry-rescued dependency (base fails, a shim succeeds) would leave the
dependent skipped instead of running.
Add retryShimJobIDs as the single source of truth for a job's shim IDs and
route both the needs: construction (writeCallbackJob, writeFinalizeJob) and
the if: gate (retrySucceededCond) through it, so the two can no longer
drift apart. A dependency with no retries is unaffected: needs: still
collapses to the bare dependency job ID.
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@github-actions

github-actionsBot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

All PR Validation checks passed.

The notify.yaml callback stub used an inline run: step whose command
contained a colon-space, which YAML reads as a nested mapping, so the
generator rejected the stub while discovering its outputs. Use a block
scalar to match the deploy stub in the same scenario.
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-templeforce-pushed the fix/dependent-deploy-retry-shim-needs branch from a36826f to 911d073CompareJuly 18, 2026 15:17
@joshua-temple
joshua-temple merged commit 148cf87 into mainJul 18, 2026
21 checks passed
@joshua-temple
joshua-temple deleted the fix/dependent-deploy-retry-shim-needs branch July 18, 2026 15:33
joshua-temple added a commit that referenced this pull request Jul 20, 2026
…nction (#645)
A generated retry shim gated its re-invocation with a bare boolean
expression (if: needs.<prev>.result == 'failure'). GitHub applies an
implicit needs-success requirement to any job whose if has no status-check
function, so the shim was skipped exactly when the previous attempt failed,
which is the only time it should run. The rescue never fired on real GitHub;
the live fleet caught it (base=failure, retry=skipped) while act, which
evaluates if leniently, ran the shim and kept the e2e harness green.
The same flaw hit a dependent of a retried deploy: its effective-result OR
gate references the base and its shims, but with no status function the
dependent was skipped the moment the base failed, before the OR could
rescue it via a shim (#639 fixed the needs list, not the if gate).
Emit the retry shim gate as ${{ !cancelled() && needs.<prev>.result ==
'failure' }} and prefix a default-policy dependent of a retried dependency
with !cancelled(). !cancelled() (not always()) is used so a cancelled run
does not force a re-deploy; the == 'failure' clause already excludes a
cancelled predecessor, so the status function only governs whole-run
cancellation. The wrap is required because a bare YAML scalar may not begin
with the "!" tag indicator. The no-retries and non-retried-dependent paths
are byte-identical.
Add a generation-correctness assertion pinning the shim and dependent if
gates (and locking the bare form out), extend the censused depends_on
assertions (GM5/GM7) with the status-function check, update the retries
e2e scenario, and codify the general rule in CONTRIBUTING.
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@joshua-temple