diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c44651f..c9f561c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,37 +308,37 @@ jobs: # automatically — no `pull_request` round-trip needed for non-fork # non-release PRs. required-check: - # The name is conditional on purpose. On an in-repo feature PR the - # `pull_request` run skips this job (the push run owns the status), and - # a skipped job still posts a check run under its name. GitHub treats a - # SKIPPED required check as satisfied — so if the skipped job were named - # `✅ Required PR Check`, it would green-light the ruleset (and enable - # auto-merge) while the push run's real mirror hadn't been created yet - # (it only appears once `ci` finishes), letting a PR merge mid-test or - # even override a red result. An expression name keeps the skipped job - # off the required name: GitHub does not evaluate the name of a skipped - # job, so it shows up as the raw expression text (still not the - # required name), while every path that runs evaluates to - # `✅ Required PR Check`. The condition is written out anyway so the - # name stays correct if GitHub ever starts evaluating it, and must stay - # identical to the `if:` below. - name: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == true || github.event.pull_request.head.ref == 'next' || github.event.pull_request.head.ref == 'hotfixes') && '✅ Required PR Check' || '⏭️ Required PR Check (reported by the push run)' }} - needs: ci - # Mirror the `ci` job's gating exactly. The four cases that run: + # Which name this job reports under is the whole point of it. + # + # The ruleset gates merges on `✅ Required PR Check`, so a check with that + # name must only ever exist on a head SHA after the full test matrix for + # that SHA has finished, mirroring its result. `needs: ci` guarantees the + # timing: the job is only created once every Build & Test leg and the + # coverage job are done. + # + # On an in-repo feature PR the `pull_request` run does not own the status + # (the push run on the head branch does), so it must not post + # `✅ Required PR Check` at all. Two traps rule out the obvious shapes: + # - A job SKIPPED by `if:` still posts a check run under its name, and + # GitHub treats a skipped required check as satisfied. Under the real + # name that let PRs merge mid-test (CLDMV/slothlet#553). + # - GitHub does not evaluate the `name:` of a skipped job, so a + # conditional name on a skippable job shows up as the raw expression + # text (#350). + # So the job never skips: it runs on every path, the name expression is + # always evaluated, and the in-repo PR path lands on a readable, + # non-required name and passes as a no-op. The condition below is + # repeated in the step's OWNS_STATUS and must stay identical. The paths + # that own the status: # 1. push events (job needs CI run) # 2. fork PRs (push doesn't cover forks) # 3. release PRs from `next` → master/main (push covers SHA but commit-gate skips chore-bump) # 4. release PRs from `hotfixes` → master/main (same reason) - # In-repo feature PRs targeting `next` / `hotfixes` skip on - # pull_request — the push run on the head branch reports the status - # on the SHA. See the `name:` above for why the skipped job is renamed. - if: | - always() && ( - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork == true || - github.event.pull_request.head.ref == 'next' || - github.event.pull_request.head.ref == 'hotfixes' - ) + name: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == true || github.event.pull_request.head.ref == 'next' || github.event.pull_request.head.ref == 'hotfixes') && '✅ Required PR Check' || '⏭️ Required PR Check (reported by the push run)' }} + needs: ci + # always(): run even when `ci` is skipped (the in-repo PR path) or failed + # (so the mirror can report red). + if: always() # Match the reusable's runner routing (workflow-ci.yml): private CLDMV # repos run on self-hosted cldmv-runners (GitHub-hosted Actions budget is # private-metered and exhausted), public repos use free GitHub-hosted, and @@ -352,10 +352,19 @@ jobs: steps: - name: Mirror reusable result env: + OWNS_STATUS: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == true || github.event.pull_request.head.ref == 'next' || github.event.pull_request.head.ref == 'hotfixes' }} IS_MASTER_SYNC: ${{ needs.ci.outputs.is_master_sync }} DOCS_ONLY: ${{ needs.ci.outputs.docs_only }} CI_RESULT: ${{ needs.ci.result }} run: | + # In-repo feature PR: the push run on the head branch reports + # `✅ Required PR Check`. This job runs under the + # `⏭️ Required PR Check (reported by the push run)` name and + # must not gate anything. + if [ "$OWNS_STATUS" != "true" ]; then + echo "In-repo PR event — the push run reports ✅ Required PR Check for this SHA." + exit 0 + fi echo "ci.result=$CI_RESULT docs_only=$DOCS_ONLY is_master_sync=$IS_MASTER_SYNC" # next/hotfixes was force-synced to master — head SHA matches the # default branch, nothing new to test, green-light without running CI.