Skip to content
fitme·story
Case studies
v7.9 · 4 min read · 2026-06-01

When Two Workflows Share a Name — The `${{ github.workflow }}` Concurrency Trap (W26)

Every mixed-content PR (touching both FitTracker source AND docs) produced two Build and Test status checks — one CANCELLED, one SUCCESS — blocking merge even with admin override. Diagnosed 2026-06-01 as W26 in the observed-patterns catalog. Root cause both ci.yml and ci-docs-skip.yml declared name CI, so their concurrency.group expressions using ${{ github.workflow }} resolved identically (ci-CI-refs/pull/N/merge) and the two files raced into mutual cancellation. Fix file-specific hardcoded group prefixes (ci-yml- and ci-docs-skip-). Now both run independently. Side benefit uncovered W28 (CoreSimulator out-of-date local env-flake) in the same session — Mac restart blocked on HADF Sub-exp 2 closure.

Build and Test checks per mixed PR
1 CANCELLED + 1 SUCCESS
ci.yml and ci-docs-skip.yml both named CI → identical concurrency.group → mutual cancellation blocks merge even with admin override.
Build and Test checks per mixed PR
2 SUCCESS
File-specific group prefixes (ci-yml- / ci-docs-skip-) → workflows run independently. Verified clean on #561 + 3 subsequent mixed PRs.
What do T1 / T2 / T3 mean?
T1Instrumented — from a ledger/commit
T2Declared — stated, not measured
T3Narrative — estimate from memory

W26 — The ${{ github.workflow }} Concurrency Trap

The symptom

PR #560 (C2 readiness-aware-training-alert) sat with:

Build and Test | CANCELLED | CI
Build and Test | SUCCESS   | CI
mergeable=MERGEABLE state=BLOCKED

Two checks with the same name. One green, one cancelled. The cancelled one was sufficient to block branch-protection merge even with --admin.

This was the second instance in the session. The first hit was the dispatcher pattern's own validation — PR #560's CI run on the W26 fix commit also produced a CANCELLED status before the fix landed on main.

The root cause

GitHub's workflow concurrency group expression evaluates ${{ github.workflow }} to the workflow name, not the file. Both files in the dispatcher pair declare name: CI:

# Before fix — ci.yml
name: CI
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}  # → "ci-CI-refs/pull/N/merge"
  cancel-in-progress: true
 
# Before fix — ci-docs-skip.yml
name: CI
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}  # → "ci-CI-refs/pull/N/merge" — IDENTICAL
  cancel-in-progress: true

On every mixed-content PR (touching both FitTracker/** AND docs/** or .claude/**):

  1. ci.yml fires (paths include FitTracker/**)
  2. ci-docs-skip.yml ALSO fires (paths-ignore not satisfied — .claude/** is outside the ignore list)
  3. Both enter the same concurrency group
  4. The second arrival cancels the first per cancel-in-progress: true
  5. The cancelled status persists in the PR's required-status-check rollup
  6. Branch protection refuses to merge

The (4-line) fix

Replace ${{ github.workflow }} with file-specific hardcoded prefixes:

# ci.yml
concurrency:
  group: ci-yml-${{ github.ref }}
  cancel-in-progress: true
 
# ci-docs-skip.yml
concurrency:
  group: ci-docs-skip-${{ github.ref }}
  cancel-in-progress: true

Each workflow keeps its own intra-workflow cancellation behavior (a new push cancels the prior run of THAT workflow on the same branch) without colliding with the sibling. Mixed PRs continue to fire both workflows; both now run to completion; branch protection accepts both Build and Test SUCCESS statuses.

Trade-off acknowledged: mixed PRs cost an extra ~5 seconds of ubuntu-latest runner time per PR. The full mutual-exclusivity fix (dorny/paths-filter job-level skip) is a separate, larger PR — deferred unless ubuntu cost becomes a real concern.

What the original 2026-05-31 W26 placeholder got wrong

The placeholder note in the 2026-05-31 carryover memory attributed the cause to cancel-in-progress: true alone. That was the symptom. The actual root cause is the cross-workflow group collision. cancel-in-progress: true per workflow is correct + desirable (a new push to the same branch should cancel the prior run of the same workflow). Only the cross-workflow group sharing was wrong.

The W28 side discovery

While diagnosing W26, every local xcodebuild invocation produced:

CoreSimulator is out of date. Current version (1051.50.0) is older than build version (1051.54.0).
xcodebuild: error: Unable to find a destination matching ...
  iOS 26.5 is not installed.

Cataloged as W28 — operator-side CoreSimulator.framework daemon binary out of date (loaded at boot before Xcode bumped the on-disk version). Fix is a full Mac restart, blocked on HADF Sub-exp 2 closure (Sub-exp 2 dispatch state doesn't survive reboot). Until then: swiftc -parse for syntax validation + xcodebuild -list for pbxproj integrity + delegate the real build to CI.

Sibling patterns

  • W17 (slot 36) — "Stale-base branch trap." Same "trust the explicit diagnostic, don't paper over with workarounds" class.
  • W28 — operator-side macOS daemon class. Both W17 + W26 + W28 reinforce that fresh-process state is the reliable recovery path on macOS-side tooling drifts.
  • The 2026-05-31 EOD session-close memory's W26 placeholder — corrected by this case study to identify the cross-workflow group sharing as the actual root cause.

Cross-references

  • Catalog entry — .claude/integrity/observed-patterns.md W26
  • W28 sibling — same catalog, env-flake entry added in PR #565
  • Sibling case study slot 36 (stale-base-branch-trap-w17.mdx)
  • v7.9 promotion context — slot 34 (framework-v7-9-promotion.mdx)
Kill criterion · not fired
  • Fix introduces NEW redundant CI runs costing >5 min per mixed PR
  • Branch protection breaks because the two workflows produce two `Build and Test` checks (would imply per-name match isn't N-of-1 — branch protection ACCEPTS multiple successes by same name)