phase-pr treats 'no pipeline yet' as 'no CI' and passes the gate before the pipeline is created #485

Closed
opened 2026-08-30 19:26:06 +00:00 by mph · 2 comments
Owner

This issue was refiled with corrected evidence on 2026-09-01. The original text claimed a
pipeline that never started; that was wrong — see the correction note at the bottom.

iw phase-pr exits 0 and writes review-state awaiting_review — documented as "CI is green, or
there is no CI" — in the window between pushing the branch and the forge creating the pipeline for
that push. Finding no pipeline, the gate reads "no pipeline" as "no CI configured" and reports the
phase verified.

Observed

MR !426 on CMI/mdr/medeca-modul-poptavky (GitLab, gitlab.e-bs.cz), issue MEDECA-447 phase 01.

Event Time (UTC)
phase-pr writes review-state awaiting_review 2026-08-28T23:58:22.678Z
GitLab creates pipeline 5258 for the push 2026-08-28T23:58:29.779Z
Pipeline 5258 starts 2026-08-28T23:58:58.279Z
Pipeline 5258 finishes, success, duration: 1253 2026-08-29T00:19:51.510Z

The gate declared the phase CI-verified 7 seconds before the pipeline existed and ~21 minutes
before it finished. The phase was merged on that basis. (The build did in fact pass, so nothing
broken shipped — but the gate's success was independent of that outcome and would have read the
same had it failed.)

Expected

After pushing, phase-pr must not conclude "there is no CI" until it has given the forge time to
create the pipeline for the pushed SHA. "No pipeline object yet" and "this project runs no CI" are
different states, and only the second one satisfies the gate.

A workable discriminator: resolve the pipeline for the pushed SHA and require either a pipeline
to appear within a grace window, or positive evidence that the project has no CI configuration at
all — rather than inferring absence from one immediate query.

A trap for whoever implements this

GET /projects/:id/merge_requests/:iid/pipelines returns started_at: null for every pipeline
it lists, including ones that ran and succeeded. Only GET /projects/:id/pipelines/:pipeline_id
carries a real started_at. Verified on the same instance: pipelines 5280 and 5281 are success
with a real started_at and duration on the single-pipeline endpoint, and started_at: null on
the list endpoint. Any check reading started_at from the list will treat every pipeline as
unstarted.

Correction to the original filing

This issue first claimed pipeline 5258 was "created and never picked up", citing status: pending
with started_at: null observed roughly two hours later. The pipeline's own record contradicts
that: it started 29 seconds after creation and finished success. The original observation almost
certainly read started_at from the MR-pipelines list endpoint (always null, see above) and paired
it with a pending status read during the first seconds. The defect above — the gate concluding
before the pipeline exists — is what the timestamps actually support.

Distinct from #445 (CI re-triggered on every invocation), #472 (whole status history on a re-run)
and #473 (corrupt ci_wait_started).


Found during MEDECA-447 (medeca-modul-poptavky); corrected while merging that issue's final MR.

**This issue was refiled with corrected evidence on 2026-09-01. The original text claimed a pipeline that never started; that was wrong — see the correction note at the bottom.** `iw phase-pr` exits 0 and writes review-state `awaiting_review` — documented as "CI is green, or there is no CI" — in the window between pushing the branch and the forge creating the pipeline for that push. Finding no pipeline, the gate reads "no pipeline" as "no CI configured" and reports the phase verified. ## Observed MR !426 on `CMI/mdr/medeca-modul-poptavky` (GitLab, gitlab.e-bs.cz), issue MEDECA-447 phase 01. | Event | Time (UTC) | |---|---| | `phase-pr` writes review-state `awaiting_review` | `2026-08-28T23:58:22.678Z` | | GitLab creates pipeline 5258 for the push | `2026-08-28T23:58:29.779Z` | | Pipeline 5258 starts | `2026-08-28T23:58:58.279Z` | | Pipeline 5258 finishes, `success`, `duration: 1253` | `2026-08-29T00:19:51.510Z` | The gate declared the phase CI-verified **7 seconds before the pipeline existed** and ~21 minutes before it finished. The phase was merged on that basis. (The build did in fact pass, so nothing broken shipped — but the gate's success was independent of that outcome and would have read the same had it failed.) ## Expected After pushing, `phase-pr` must not conclude "there is no CI" until it has given the forge time to create the pipeline for the pushed SHA. "No pipeline object yet" and "this project runs no CI" are different states, and only the second one satisfies the gate. A workable discriminator: resolve the pipeline **for the pushed SHA** and require either a pipeline to appear within a grace window, or positive evidence that the project has no CI configuration at all — rather than inferring absence from one immediate query. ## A trap for whoever implements this `GET /projects/:id/merge_requests/:iid/pipelines` returns `started_at: null` for **every** pipeline it lists, including ones that ran and succeeded. Only `GET /projects/:id/pipelines/:pipeline_id` carries a real `started_at`. Verified on the same instance: pipelines 5280 and 5281 are `success` with a real `started_at` and `duration` on the single-pipeline endpoint, and `started_at: null` on the list endpoint. Any check reading `started_at` from the list will treat every pipeline as unstarted. ## Correction to the original filing This issue first claimed pipeline 5258 was "created and never picked up", citing `status: pending` with `started_at: null` observed roughly two hours later. The pipeline's own record contradicts that: it started 29 seconds after creation and finished `success`. The original observation almost certainly read `started_at` from the MR-pipelines list endpoint (always null, see above) and paired it with a `pending` status read during the first seconds. The defect above — the gate concluding before the pipeline exists — is what the timestamps actually support. Distinct from #445 (CI re-triggered on every invocation), #472 (whole status history on a re-run) and #473 (corrupt `ci_wait_started`). --- Found during MEDECA-447 (medeca-modul-poptavky); corrected while merging that issue's final MR.
mph changed title from phase-pr treats a pipeline that never started as satisfied to phase-pr treats 'no pipeline yet' as 'no CI' and passes the gate before the pipeline is created 2026-09-01 06:32:59 +00:00
Member

Same window, opposite verdict, on Forgejo (found during IW-583):

iw phase-pr, resumed after a killed run, pushed a card commit and at once reported "CI did not run" (card ci_failed) while Forgejo run 890 for that commit had started. A second call waited and passed. The gate reads the checks before the forge registers the run for the pushed SHA — here it fails closed instead of passing open, but the missing grace window is the same defect.

Same window, opposite verdict, on Forgejo (found during IW-583): `iw phase-pr`, resumed after a killed run, pushed a card commit and at once reported "CI did not run" (card `ci_failed`) while Forgejo run 890 for that commit had started. A second call waited and passed. The gate reads the checks before the forge registers the run for the pushed SHA — here it fails closed instead of passing open, but the missing grace window is the same defect.
Member

The code on main (1ab91787) no longer has this path.

phase-pr no longer infers "no CI" from an empty pipeline list. It asks CiPresence.isCiExpected (core/model/CiPresence.scala), which reads the workflow files out of the commit under test — .gitlab-ci.yml, .forgejo/workflows, .gitea/workflows, .github/workflows — and an unreadable tree is a Left, never a false. CiVerdict.NoCi has exactly two producers, core/commands/PhasePr.scala:420 and core/model/CiGate.scala:499, and both are guarded by !ciExpected. No pipeline query can reach that verdict.

The grace window this issue asked for also exists: CiGateConfig.runAppearanceBudgetMs (5 minutes) holds an empty or sha-mismatched run list at Running before emptyVerdict may call it NoRunForEvent (core/model/CiGate.scala:492-508). CiGateTest covers both sides of that boundary.

The fix landed in 0881a351 (2026-08-11, "verdict model that never reads absence of evidence as green") and reached phase-pr in 10fc4b09 (2026-08-12). MR !426 was observed on 2026-08-28, so the project was almost certainly running an older installed iw; the refile on 2026-09-01 reused that evidence without re-reading the code.

The list-endpoint trap this issue documented — started_at: null on GET /projects/:id/merge_requests/:iid/pipelines — stays true and stays worth knowing.

The IW-583 comment above is a different defect: there the gate failed early rather than passing early, because a stale ci_wait_started had already spent the appearance budget for a head that was seconds old. It is recorded on #491, which is where the fix belongs.

Closing as implemented.

The code on `main` (1ab91787) no longer has this path. `phase-pr` no longer infers "no CI" from an empty pipeline list. It asks `CiPresence.isCiExpected` (`core/model/CiPresence.scala`), which reads the workflow files out of the commit under test — `.gitlab-ci.yml`, `.forgejo/workflows`, `.gitea/workflows`, `.github/workflows` — and an unreadable tree is a `Left`, never a `false`. `CiVerdict.NoCi` has exactly two producers, `core/commands/PhasePr.scala:420` and `core/model/CiGate.scala:499`, and both are guarded by `!ciExpected`. No pipeline query can reach that verdict. The grace window this issue asked for also exists: `CiGateConfig.runAppearanceBudgetMs` (5 minutes) holds an empty or sha-mismatched run list at `Running` before `emptyVerdict` may call it `NoRunForEvent` (`core/model/CiGate.scala:492-508`). `CiGateTest` covers both sides of that boundary. The fix landed in 0881a351 (2026-08-11, "verdict model that never reads absence of evidence as green") and reached `phase-pr` in 10fc4b09 (2026-08-12). MR !426 was observed on 2026-08-28, so the project was almost certainly running an older installed `iw`; the refile on 2026-09-01 reused that evidence without re-reading the code. The list-endpoint trap this issue documented — `started_at: null` on `GET /projects/:id/merge_requests/:iid/pipelines` — stays true and stays worth knowing. The IW-583 comment above is a different defect: there the gate failed early rather than passing early, because a stale `ci_wait_started` had already spent the appearance budget for a head that was seconds old. It is recorded on #491, which is where the fix belongs. Closing as implemented.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/iw-cli#485
No description provided.