Repository navigation
ci(readiness): scope concurrency to bound PR refreshes - #2423
Conversation
Squad PR Readiness used a workflow-level concurrency group keyed by head repository and branch. Every workflow_run delivery joins that group before any job's if: is evaluated. So skipped runs (fork and push runs of Squad CI, cancelled requests) cancelled real checklist refreshes, and PRs from one head branch to different bases cancelled each other. Split the writer into two jobs. bind joins no group and exposes the binding as job outputs. readiness runs only for a bound PR and joins squad-pr-readiness-<PR number> with cancel-in-progress. The bind job is named readiness-bind and added to SELF_CHECK_NAMES, because workflow_run check runs land on the dev tip, which is the head of any PR from dev. The split tests now check each job's checkout, require dependent jobs to wait for the binding, and simulate which group each delivery joins. Closes #2420 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
🟡 Impact Analysis — PR #2423Risk tier: 🟡 MEDIUM 📊 Summary
🎯 Risk Factors
📦 Modules Affectedci-workflows (1 file)
scripts (1 file)
tests (2 files)
This report is generated automatically for every PR. See #733 for details. |
🛫 PR Readiness Check
PR Scope: 🔧 Infrastructure
|
| Status | Check | Details |
|---|---|---|
| ❌ | Single commit | 2 commits — consider squashing before review |
| ✅ | Not in draft | Ready for review |
| ✅ | Branch up to date | Up to date with dev |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | No source files changed — changeset not required |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | No merge conflicts |
| ✅ | Copilot threads resolved | All 1 Copilot thread(s) resolved |
| ❌ | CI passing | 5 check(s) failing: samples-build, sdk-exports-validation, test, Policy Gates, gh-aw strict compile |
Files Changed (4 files, +448 −38)
| File | +/− |
|---|---|
.github/workflows/squad-pr-readiness.yml |
+48 −13 |
scripts/pr-readiness.mjs |
+46 −3 |
test/pr-readiness.test.ts |
+107 −4 |
test/pull-request-workflow-split.test.ts |
+247 −18 |
Total: +448 −38
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
🏗️ Architectural Review
Automated architectural review — informational only. |
🔒 Security Review
🔒 Security review: 1 warning(s).
Automated security review — informational only. |
There was a problem hiding this comment.
🟡 Changes recommended
The posting path does not revalidate the bound SHA, allowing a late job for an older PR head to overwrite the current head’s failing checklist with stale passing results.
1 open issue
What changed in this PR
Moves PR readiness deduplication behind PR binding to prevent skipped deliveries and different PRs from cancelling each other’s checklist refreshes, addressing #2420.
Changes:
- Splits binding and posting into separate jobs, with PR-keyed concurrency only on the bound posting job.
- Excludes the new
readiness-bindcheck run from the checklist’s CI evaluation. - Extends workflow tests for job dependencies, bound inputs, and concurrency isolation.
| File | Description |
|---|---|
test/pull-request-workflow-split.test.ts |
Adds job-aware contracts and concurrency simulations. |
scripts/pr-readiness.mjs |
Excludes the bind job’s check-run name from CI evaluation. |
.github/workflows/squad-pr-readiness.yml |
Separates binding from posting and applies PR-scoped concurrency. |
Review effort: Max (auto)
The bind job checks the PR head outside the posting job's concurrency group. A delivery could therefore bind head A, reach the posting job after head B was pushed and refreshed, and overwrite B's checklist with A's: scripts/pr-readiness.mjs queried CI for A without comparing it with the live PR head. run() now compares PR_HEAD_SHA with the live PR head when it first reads the PR, before any CI query, and again just before it writes. On a mismatch it logs one line naming both SHAs, writes nothing and exits 0. If the PR cannot be read before writing, it fails without writing. The posting job's group is now keyed by PR number and head SHA. Keyed by PR alone, such a late job could cancel the current head's refresh (cancel-in-progress) or replace it while pending, then skip as stale, so no checklist was posted for the current head. Refreshes of one head still share a group, so the newer one wins. Tests cover out-of-order completion: a late run, a head that moves while the checks run, an unreadable head, and a matching head that still writes. The concurrency simulation checks that a late run bound to an older head joins a different group from the current head's refresh. Closes #2420 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The privileged workflow_run writer refactor relies on GitHub concurrency and check-run placement behavior that can only be validated through post-merge runs on the default branch.
No open issues 🎉
1 resolved since last review
Review effort: Max (auto)
…#2436) #2409 moved the impact, Post Repo Health findings and heartbeat writers onto workflow_run jobs. Their check runs attach to the default-branch tip, not the PR head, and squad-impact.yml / squad-repo-health-report.yml cancel superseded runs, so a quick push on any PR leaves a `cancelled` check on dev. checkCIStatus() treats cancelled/failure as failing CI, so every dev-to-main PR reported "check(s) failing: impact" based on unrelated PRs' runs. readiness-bind was already excluded for this reason (#2423); this adds the remaining workflow_run writer names. Co-authored-by: Copilot <Copilot@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

What
Scopes the Squad PR Readiness writer's concurrency to bound PR refreshes.
.github/workflows/squad-pr-readiness.ymldrops its workflow-level group, keyed by head repository and branch, and splits into two jobs:bind, namedreadiness-bind, validates the run, exposes the bound PR as job outputs, and joins no group.readinessruns only for a bound PR. It joinssquad-pr-readiness-<PR number>-<head SHA>withcancel-in-progress.scripts/pr-readiness.mjsnow writes the checklist only while the PR's head is still the bound head.Working as github-integration-dev (GitHub Integration Engineer).
Why
#2409 moved readiness to a
workflow_runwriter with a workflow-level group keyed by head branch. Every delivery joins that group before any job'sif:is evaluated. So:dev,main,insider) cancel each other.Before #2409 the group was keyed per PR, so neither could happen. Copilot code review found this on #2409 (thread).
Closes #2420
How
Bind first, deduplicate only bound runs:
bindkeeps the existing gatingif:, the claim download and the binding step. It publishesbound,pr_number,head_sha,base_ref,draftandlabelsas job outputs.readinesshasneeds: bindandif: needs.bind.outputs.bound == 'true'. Only then does it join its job-level group, keyed by PR number and head SHA.PR_*inputs now come only fromneeds.bind.outputs.*.Stale snapshots (from Copilot's review of this PR):
bindchecks the head outside any group. A delivery can therefore bind head A and reachreadinessafter head B was pushed and refreshed.run()comparesPR_HEAD_SHAwith the live PR head twice: when it first reads the PR, before any CI query, and again with a fresh read just before writing.cancel-in-progress: false: a late job could still replace B's pending job.queue:key: actionlint 1.7.12, whichsquad-workflow-lint.ymlpins, rejects it.Check-run names:
workflow_runcheck runs land on the default-branch tip, which is the head commit of anydev→mainPR. Soreadiness-bindjoinsreadinessinSELF_CHECK_NAMES(scripts/pr-readiness.mjs). Otherwise a running or failing bind job, which a fork can trigger with a malformed claim, would show in that PR's "CI passing" item. Required checks are unaffected.Checkouts: each job checks out only the script it runs, from the default branch, with
persist-credentials: false.scripts/pr-readiness.mjsimports only Node built-ins, so the single-file sparse checkout is enough.Tests (
test/pull-request-workflow-split.test.ts): the existing contracts are now job-aware:New assertions:
readiness, keyed by PR number and head SHA;SELF_CHECK_NAMES;dev, a Dependabot run, a cancelled request, an unbound request, and a forged claim;dev→mainPR each get their own group;Tests (
test/pr-readiness.test.ts): out-of-order completion with a mockedfetch:Behavior notes:
readinessjob, and that check-run name is excluded from "CI passing".Out of scope:
squad-impact.ymlandsquad-repo-health-report.ymlkeep branch-keyed groups. Each has one trigger and onlydevas base, so neither case applies (see PR readiness: workflow-level concurrency lets skipped runs cancel checklist refreshes #2420).pull-requests: writefrom the bind job (least privilege) is left for later; the existing test forbids job-level permissions.Validation (Windows, Node 27; branch rebased on
dev):npx vitest run test/pull-request-workflow-split.test.ts test/workflow-run-pr-binding.test.ts test/pr-readiness.test.ts.github/workflows/squad-pr-readiness.ymlrestored fromdevsquad-pr-readiness.yml: a Squad CI run of a fork PR, which lists no PR joined "Squad PR Readiness-contributor/squad-feature"80b88597, new tests kept)the run bound to aaaa… wrote while PR #42 is at bbbb…, andthe late run bound to aaaa… joins "squad-pr-readiness-7", the group of the refresh for current head bbbb…if:orneeds, a branch-keyed group,cancel-in-progress: false, a group onbind, re-adding the workflow-level group,persist-credentials: true, a checkoutref:,PR_NUMBERfrom the payload, a bind output bypassing the binding, removingreadiness-bindfromSELF_CHECK_NAMES, and others. Stale-snapshot round: dropping either head check, writing when the pre-write read fails, a silent skip, treating every head as stale, failing instead of exiting 0, and a group keyed only by PR or only by SHAnode scripts/pr-readiness.mjswith a stubbedfetchSkipping stale readiness snapshot for PR #42: bound head aaa… is not the live head bbb…; nothing was written., writes nothing and exits 0; a matching head creates the commentnode --test test/ci-concurrency.test.cjsactionlint1.7.12 with shellcheck 0.10.0 over.github/workflows/*.yml(.github/actionlint.yaml)needs.bind.outputs.*referencesnpx eslint test/pr-readiness.test.ts test/pull-request-workflow-split.test.ts scripts/pr-readiness.mjsNot run locally:
npm run build, the fullnpm test, andnpm run lint. No package source changed, and CI runs them.npx changeset add/.changeset/*.md, directCHANGELOG.mdentry for maintainers, orskip-changeloglabel for no user-facing changes): not applicable, no SDK or CLI files changedPR Readiness Checklist
Branch & Commit
dev(notmain)dev(git fetch upstream && git rebase upstream/dev)git diff --cached --stat): 4 files, no deletionsBuild & Test
npm run buildpasses: not run locally; no package source changed, and CI buildsnpm testpasses (all tests green): the targeted suites above; CI runs the full suitenpm run lintpasses (type check clean): not run; no TypeScript underpackages/changednpm run lint:eslintpasses: ran ESLint on the changed test files and the scriptChangeset
npx changeset add(ifpackages/squad-sdk/src/orpackages/squad-cli/src/changed): not needed; only a workflow, a repo script and tests changedCHANGELOG.mdentry (maintainers only — write-protected for external contributors)skip-changeloglabel applied (if no user-facing changes)Docs
Exports
Breaking Changes
None. The readiness comment and checklist format are unchanged. A run whose bound head is no longer the PR's head now writes nothing. A new check-run name,
readiness-bind, appears on the default-branch tip and is excluded from "CI passing".Waivers
None.