Skip to content

fix(ci): stop a skipped retry job taking the whole deploy chain with it - #1008

Merged
simonoppowa merged 1 commit into
mainfrom
hotfix/deploy-chain-skip-propagation
Sep 1, 2026
Merged

fix(ci): stop a skipped retry job taking the whole deploy chain with it#1008
simonoppowa merged 1 commit into
mainfrom
hotfix/deploy-chain-skip-propagation

Conversation

@simonoppowa

Copy link
Copy Markdown
Owner

Merging #988 shipped nothing. Push run 33547853809 reported success with every check green, and the entire deploy chain was skipped:

ios-integration-tests / run     success
ios-integration-tests-retry     SKIPPED     ← skips on every healthy run
ios-integration-tests-result    success     ← has  if: !cancelled()
ios-package                     SKIPPED
ios-deploy                      SKIPPED
android-package                 SKIPPED
android-deploy                  SKIPPED
github-release                  SKIPPED

No v2.2.0 tag, no GitHub release, nothing to TestFlight, no AAB. The run was green, so nothing looked wrong.

Cause

2.2.0's own two-runner iOS integration change introduced it. ios-integration-tests-retry is skipped whenever the first runner passes — the normal case — and a skip travels the whole dependency chain, not one hop. ios-integration-tests-result steps out of the way with its own if: !cancelled(), but that exempts that job; the skip keeps going into the deploy jobs, whose if: contained no status function and so inherited GitHub's implicit success().

All five of ios-package's direct needs concluded success and it skipped anyway. The pipeline only deployed when the first iOS runner failed.

The A/B is clean: run 33243607505 fired byte-identical guards on the same event and deployed. The sole structural difference is that the package jobs then needed ios-integration-tests directly, before any job that skips on a healthy run existed upstream of them.

Ruled out with evidence: the if: | block scalar (byte-identical where deploy worked), the guard predicate (event=push, head_branch=main), reusable-workflow uses: jobs misbehaving in needs, and ios-podfile-lock-guard (skipped in both runs — it is in nobody's needs, which also shows the taint travels only along needs edges).

The fix

Including a status function drops the implicit success() and with it the inherited skip. That is also why each dependency now has to be asserted by hand: a bare !cancelled() would be strictly worse than the bug, because it would push an integration suite that failed on two independent runners straight to TestFlight and Play.

So every needs: entry gets an explicit result == 'success', and the comment on ios-package states the rules for keeping the two lists in step — == 'success' not != 'failure', !cancelled() not always(), and the event test kept as an AND-conjunct.

Verification

Outcome matrix, evaluated against the real conditions parsed out of the file:

case ships
push main, first runner passes — the bug ✅ yes
push main, first failed, retry passed ✅ yes
workflow_dispatch on main ✅ yes
pull request, all green ❌ no
both iOS runners failed ❌ no
android-integration-tests / linux-checks / ios-build failed ❌ no
run cancelled mid-flight ❌ no
push to develop ❌ no
result job itself skipped ❌ no

actionlint 1.7.7 reports no new findings (8 pre-existing macos-26 unknown-label warnings on both baseline and patched). A structural check confirms all five guards mirror their needs: exactly, no dangling dependencies, and both atomicity comments intact.

Merging this is what deploys 2.2.0

The push to main runs the pipeline with the fixed guards. main is already at 2.2.0+63 and no v2.2.0 tag exists, so it cuts fresh.

Expect android-deploy to go green with the Play upload undone — that is the #942 tolerance, not success. Read the step summary, upload the AAB by hand, and read Play's warnings on the review step.

Not included, deliberately

workflow_dispatch has no ref test, and the trigger has no branch filter — a dispatch from any branch passes the guard and ships. This is not theoretical: run 26086317616 cut a release from integrate-cicd-deployment. Closing it is a one-line change to all five guards, but it belongs in its own commit so that reverting it cannot revert this fix.

Merging 2.2.0 to `main` shipped nothing. Push run 33547853809 reported
`success` with every check green, and `ios-package`, `ios-deploy`,
`android-package`, `android-deploy` and `github-release` were all
skipped: no v2.2.0 tag, no GitHub release, nothing to TestFlight, no
AAB. Nothing looked wrong, which is the worst part.

The cause arrived with 2.2.0's own two-runner iOS integration change.
`ios-integration-tests-retry` is skipped on every *healthy* run — the
first runner passed, so there is nothing to retry — and a skip travels
the whole dependency chain, not one hop. `ios-integration-tests-result`
steps out of the way with its own `if: !cancelled()`, but that exempts
that one job; the skip keeps going into the deploy jobs, whose `if:`
carried no status function and so inherited GitHub's implicit
`success()`. All five of `ios-package`'s direct needs succeeded and it
skipped anyway.

So the pipeline only deployed when the first iOS runner *failed*. The
A/B is clean: run 33243607505 fired the same guards on the same event
and deployed, and the sole structural difference is that the package
jobs then needed `ios-integration-tests` directly, before any job that
skips on a healthy run existed upstream of them.

Including a status function drops the implicit `success()` and with it
the inherited skip, which is why each dependency is now asserted by
hand. A bare `!cancelled()` would have been strictly worse than the bug
— it would push an integration suite that failed on two independent
runners straight to TestFlight and Play — so every `needs:` entry gets
an explicit `result == 'success'`, and the comment on `ios-package`
spells out the rules for keeping the two lists in step.

Verified against the full outcome matrix. The three cases that must
ship do: first runner passes, first fails and the retry passes, and
dispatch on main. The eight that must not, do not: pull request, a
failed integration result, a failed linux-checks / ios-build /
android-integration-tests, a cancelled run, a push to develop, and a
skipped result job. actionlint reports no new findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The workflow changes correctly prevent transitive skip propagation while still enforcing that all required needs jobs must have succeeded before packaging, deploying, or cutting a release.

Pull request overview

This PR fixes a GitHub Actions deploy-chain regression where a skipped retry job (ios-integration-tests-retry) could propagate “skip” through needs: and silently prevent packaging/deploy/release jobs from running—even when all required checks were green.

Changes:

  • Adds explicit !cancelled() plus per-needs result == 'success' checks to all deploy-gate jobs to avoid inheriting implicit success() skip propagation.
  • Ensures deploy/release jobs only run when the workflow is a push to main or a workflow_dispatch, and only when every required upstream job actually succeeded.
  • Documents the rationale and maintenance rules for keeping needs: and if: conditions in sync across the five gated jobs.
File summaries
File Description
.github/workflows/default_workflow.yml Makes deploy/package/release gating robust against transitive “skipped” propagation by using explicit status functions and per-need success assertions.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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.

2 participants