Skip to content

fix(generate): gate retry shims and their dependents with a status function - #645

Merged
joshua-temple merged 1 commit into
mainfrom
fix/retry-shim-always-gate
Jul 20, 2026
Merged

fix(generate): gate retry shims and their dependents with a status function#645
joshua-temple merged 1 commit into
mainfrom
fix/retry-shim-always-gate

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

cascade's retry-rescue feature was broken on real GitHub. A generated retry shim gated its re-invocation with a bare boolean expression:

deploy-app-retry-1:
  needs: [setup, deploy-app]
  if: needs.deploy-app.result == 'failure'

GitHub applies an implicit needs-success requirement to any job whose if contains no status-check function (always(), !cancelled(), success(), failure(), cancelled()). A bare boolean does not lift that implicit gate, so when deploy-app fails, deploy-app-retry-1 is skipped rather than run. The rescue never fired.

The same flaw hit a dependent of a retried deploy: its effective-result OR gate references the base plus its shims, but with no status function the dependent was skipped the moment the base failed, before the OR could rescue it. (#639 fixed the dependent's needs: list but not its if gate.)

finalize already used if: always() && ... correctly; only the retry shims and dependents-of-retried-deploys were missed.

Why it escaped: this emits valid YAML that actionlint accepts, and act evaluates if leniently enough to run the skipped-on-real-GitHub job, so unit tests and the e2e harness stayed green. Only the live fleet caught it (base=failure, retry=skipped).

Fix

  • Retry shim: if: ${{ !cancelled() && needs.<prev>.result == 'failure' }} for every rung (retry-1, retry-2..N).
  • Dependent of a retried dependency (default policy): prefix its if with !cancelled() &&, only when the dependency actually declares retries.

!cancelled() rather than always(): a retry re-invokes a deployment, so a cancelled run must not force a re-deploy. The == 'failure' clause already excludes a cancelled predecessor (its result is 'cancelled', not 'failure'), 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 needs: lists and the finalize/state-write always() gates are unchanged. The no-retries and non-retried-dependent paths are byte-identical.

Census gap this closes

The generation-correctness census walks string-carrying manifest fields; retries is an int, so the shim if shape sat outside it, which is why the defect escaped. This PR adds a named correctness assertion for the shim and dependent gates and folds the status-function check into the censused depends_on assertions (GM5/GM7) so a regression reds.

Verification

  • New TestGenCorrectness_RetryShim_IfGateCarriesStatusFunction (red before, green after); extended GM5/GM7.
  • E2E scenario 73-deploy-retries.yaml asserts the wrapped ${{ !cancelled() && ... }} form and locks the bare form out via not_contains.
  • go build ./..., go test ./... (3654 pass), go test ./internal/generate -race, golangci-lint run ./... all clean; e2e module builds and vets.
  • cascade's own manifest declares no retries, so its generated workflows are unchanged (no drift).
  • General rule codified in CONTRIBUTING.md.

…nction

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>
@joshua-temple
joshua-temple merged commit d90b70a into main Jul 20, 2026
21 checks passed
@joshua-temple
joshua-temple deleted the fix/retry-shim-always-gate branch July 20, 2026 13:51
Sign up for free to 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