Skip to content

ci: heavy CI never runs on a PR whose base is a feature branch — lint.yml / ci.yml pull_request triggers are limited to branches: [main], so six required contexts report nothing on such PRs (item 3 of #16149) #16482

Description

@os-zhuang

Split out of #16149 by director ruling (decision batch #66, 2026-09-07, option C): this item stands on its own — it is true of any PR based on a feature branch, not only of stacked series, which merely exposed it.

Measured (#16149, triage re-verified on origin/main)

# .github/workflows/lint.yml            # .github/workflows/ci.yml
on:
  pull_request:
    branches: [main]                        branches: [main]

A PR whose base is a feature branch triggers neither workflow. Across the six-card #14478 stack every card PR showed 8–11 light checks (Auto Label, Check Changeset, PR Size, docs drift, …) and zero of Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Dogfood Regression Gate, Temporal Conformance. The first real signal arrived at the trunk PR, after every card was written, reviewed and merged; correctness rested on local runs quoted into PR bodies — a discipline, not a gate.

Scope

  • Make the required contexts report on PRs regardless of base branch (widen the pull_request trigger, or add a pull_request trigger without a branches filter for the heavy workflows), after reading and honouring the two comment blocks the current triggers carry: ci.yml:12-15 (every workflow producing a required check MUST keep the merge_group trigger or queue builds wait forever) and lint.yml:16 onward (the MEASURED 2026-08-25 / [finding] Merge-queue check-set parity for tree-global ratchets — measure which lint.yml jobs actually run on merge_group, then close the gap the stale-ledger outage rode through #12211 negative result), plus the concurrency cancel policy at ci.yml:17.
  • ⚠️ Runner cost rises: measure and state the expected increase (PRs per week with a non-main base × the heavy matrix) on this card before landing; if it is large, gate the heavy run on a label or on the base branch being an open PR's head rather than running for every feature-branch PR.
  • Pin: a workflow-lint test (or the existing ci-cd-pipeline-doc.test) asserting that every workflow producing a branch-protection-required check runs on pull_request for any base.

Out of scope

Acceptance

  • a PR based on a feature branch shows all six required contexts
  • merge_group behaviour unchanged (queue builds still report)
  • runner-cost delta recorded on this card
  • pin present

Refs #16149, #12211, #12933.

Activity

  1. claude commented on Sep 8, 2026

    @claude
    Contributor

    Claim: PM loop round 2
    Session: session_012GKcPZbMoGq7WPzKLfRBTU
    Branch: claude/issue-16482-heavy-ci-any-base
    Worktree: objectstack-issue-16482
    Domain: domain:devx
    File surface: .github/workflows/ci.yml + .github/workflows/lint.yml + the pin (a workflow-lint test, or ci-cd-pipeline-doc.test) + .changeset/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — default judgement tier (TIER_DEFAULT). ⛔ Not the floor tier: the card leaves a conditional design choice open ("if [the cost] is large, gate the heavy run on a label or on the base branch being an open PR's head"), and choosing between those is a judgement about CI economics, not a substitution.
    Clause-②: no
    Reason for no: the diff is confined to .github/workflows/** plus a test. packages/spec/src/** is not touched, no published surface moves, and nothing an author may write narrows — the change makes existing required checks report on more PRs. Judged from content.
    Thread-read: the card body in full; #16149's ruling is quoted in it, no second fetch required for the fence, but read #16149 if a scope question arises.
    Serial constraints cleared: all 19 open PRs' changed-file lists fetched paged to exhaustion at 2026-09-08T14:1xZ and filtered for .github/workflows/ci.yml and .github/workflows/lint.yml — zero hits; control, 19 of 19 returned a non-empty list. #16395's PR #16868 (the last ci.yml writer) MERGED 13:48:05Z, so ci.yml is free.

    ⚠️ Same-file cards held behind this one, by this seat

    ci.yml is the hottest file in this lane. These are pm:queue and will not be dispatched while this card holds the file: #16467, #16454, #16494, #16717, and #16886 (filed by this seat 14:1xZ, untriaged). ⇒ You hold the file; land promptly, and ⛔ do not widen into any of theirs — a passing mention of one in your PR body is fine, a line of their fix is not.

    What this card actually owes, in order

    ⭐ The measurement is not optional and it is not a footnote — it is an acceptance row. The card's own ⚠️ says the runner cost rises. Take the number before you pick the shape of the fix:

    1. Measure: PRs per week whose base is not main × the heavy matrix. Get the population from the API (GET /pulls?state=all over a stated window, filtered on base.ref != 'main'), and the matrix width from ci.yml itself. State the window, the count, and the arithmetic in the PR body and back on this card.
    2. Then choose: widen the trigger unconditionally only if that number is small. If it is large, take the card's own fallback — a label gate, or "the base branch is the head of an open PR". ⛔ Do not pick the unconditional widening because it is the shorter diff and then leave the cost unstated.
    3. ⛔ merge_group must survive. ci.yml:12-15 says in terms that every workflow producing a required check MUST keep its merge_group trigger or queue builds wait forever. Read that block and the concurrency cancel policy at :17 before you edit the on: key, and show in the PR body that the merge-queue path is unchanged.
    4. ⛔ Read lint.yml:16 onward before widening lint.yml. It records a MEASURED negative result (2026-08-25 / [finding] Merge-queue check-set parity for tree-global ratchets — measure which lint.yml jobs actually run on merge_group, then close the gap the stale-ledger outage rode through #12211). If that measurement is the reason for the branches: [main] filter, your change must say why it no longer binds — or route around it. Do not silently delete a comment that records a measurement.
    5. The pin: a test asserting that every workflow producing a branch-protection-required check runs on pull_request for any base. Derive the required-context list rather than transcribing it if the repo gives you a way; if you transcribe, say so and pin the transcription.

    Reporting

    ⚠️ Acceptance row 1 ("a PR based on a feature branch shows all six required contexts") cannot be verified by your own PR unless your PR's base is itself a feature branch — and it should not be. ⇒ Do not claim it. Name it as open verification residue in your report, state exactly what run would confirm it, and leave it to this seat. A claim you cannot drive is worse than a stated gap.


    Generated by Claude Code

  2. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16482,
      "status": "done",
      "branch": "claude/issue-16482-heavy-ci-any-base",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16893",
      "premise_still_valid": true,
      "summary": "Removed the `branches: [main]` filter from the `pull_request:` trigger of both .github/workflows/ci.yml and .github/workflows/lint.yml, so all six branch-protection-required contexts now report on a PR regardless of its base branch. The entire workflow diff removes exactly four lines (the two `branches:`/`- main` pairs); `push:` keeps its main-only filter deliberately (it is per-branch, not per-PR) and `merge_group:` plus both `concurrency` blocks are byte-unchanged. The measurement was taken BEFORE the shape was chosen and selects unconditional widening: over the complete 30-day window 2026-08-09 to 2026-09-08, 6 of 4000 PRs (0.15%) had a non-main base, all six the #14478/#15679 stack; at 23 heavy job-runs per PR event and a measured 1.75 runs/PR that is ~57 job-runs/week, and even the pessimistic one-run-per-commit bound (115 commits) is 1.6% of current pull_request spend. The pin is assertion (7c) in scripts/check-required-contexts.mjs, deriving its context list from the existing REQUIRED_CONTEXTS registry rather than transcribing it.",
      "tests": "ALL exit codes captured before any pipe (`cmd > log 2>&1; EXIT=$?`), never through `| tail`. Gate families derived mechanically: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` -> 58 families from the committed 3-path diff (three-dot vs merge base c1d8f98a5). Reconciled with `--ran`: 'Run reconciliation - 58 derived, 53 run, 4 NOT-MEASURED, 1 UNRUN.' | GREEN (53), including: 'check-required-contexts: 6 required context name(s) pinned across 2 workflow(s); 6 instruction surface(s) scanned' EXIT=0; 'check-required-contexts --self-test: 158 assertions' EXIT=0; 'check-nul-bytes: OK (scanned 8350 text file(s) -- 8350 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes)' EXIT=0; 'check-partof-closing-keyword self-test: 95 cases pass' EXIT=0; check:ci-filter-parity, check:workflow-status-functions, check:shard-attestation, check:select-gate-families, check:type-check-coverage, check:required-contexts and 43 others all EXIT=0. | NOT MEASURED (4), each the gate refusing with its own stated prerequisite (a built tree), and each unreachable from this diff since it moves no package source byte: check:dts-closure EXIT=3 ('NOT a pass and NOT a finding: nothing was swept'), check:dual-build-cjs-loads EXIT=3 ('Run `pnpm build` first. NOT a pass: nothing was measured', 101 packages without dist), check:sourcemap-no-sources-content EXIT=3, check:type-check-debt EXIT=3. | UNRUN (1), declared rather than hidden: check:pm-dispatch-gates was cap-killed at 240s and again at 540s (EXIT=124) with no failing assertion in its output; a cap kill is not a refused prerequisite, and that tool's own docblock names precisely that substitution as where an unfinished run hides, so it is recorded UNRUN for CI. | ABLATION (reverse verification of the new pin, on the real tree, from the committed state): restored `branches: [main]` on ci.yml under a `trap ... EXIT INT TERM` with absolute paths. On-disk proof before reading any result: '    branches:' lines went 1 -> 2, HEAD blob f0b24f6b00b6217e8f5f73079cb62508995041a8 -> mutated e58de99e82da687edc4792ced5f643f3875da57a (non-empty, differing). MUTATED GATE EXIT=1 naming the intended message: \"ci.yml's `pull_request:` trigger carries `branches:`. A base-filtered trigger does not run at all on a PR whose base branch is outside the filter, so it publishes NO check run there - not a skip, an absence\". Restoration by `git checkout HEAD -- <abs path>`: restored blob hash f0b24f6b00b6217e8f5f73079cb62508995041a8 == HEAD, and `git diff HEAD` EMPTY. No ablation artefact remains. The self-test also reds under the same mutation, but via a duplicate-key YAML parse error (the (7c) fixture re-injects `branches:` into an already-filtered trigger), so the GATE run above is the clean directional read and is the one quoted. | MERGE-QUEUE EVIDENCE: `git diff c1d8f98a5c..HEAD -- .github/workflows/` has no '-' line touching merge_group, concurrency, group: or cancel-in-progress; the only removals in the whole workflow diff are the four `branches:`/`- main` lines. `merge_group:` still present exactly once per file; both concurrency blocks byte-identical.",
      "mcp_calls": "0 - every GitHub read and write went through curl REST; the MCP GitHub server was not called at all",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the card and the PM claim both offer `ci-cd-pipeline-doc.test` as an existing pin location -- NO such file exists anywhere in the repo (searched by name and by content). The pin therefore went to the 'workflow-lint test' alternative the card also offers. Successor: the next seat that reads this card's Pin bullet.",
        "noted, not filed: a `pull_request` `synchronize` fires on HEAD movement, not BASE movement, so a stacked PR still does not re-run when the branch it is based on is updated. This is a real remaining hole in stacked-PR signal, but it is a different defect from the trigger filter and is moot while stacks are ruled an unsupported form (#16149). Successor: whoever revisits the #16149 ruling.",
        "noted, not filed: `scripts/check-required-contexts.mjs` assertion 7 covers `paths:`/`paths-ignore:` and 7b covers `types:`, but neither the header nor the code carried any note that `branches:` was the third axis of the same trigger and equally fatal -- which is why the defect survived the #5617 audit that wrote assertions 6 and 7. Now closed by (7c) in this PR; recorded only as the reason the gap existed. Successor: none.",
        "noted, not filed: origin/main advanced from c1d8f98a5c to eabdd66f45 during this run, and dispatch-gates flagged a STALE TREE on scripts/doc-authoring-prose-id.baseline.json. The branch is not rebased; the derived family set is unaffected (three-dot merge-base semantics) and CI builds the merge result. Successor: none."
      ],
      "deviations_from_the_brief": [
        "FILE SURFACE, widened by one file, declared not silent. The claim's surface was ci.yml + lint.yml + 'the pin (a workflow-lint test, or ci-cd-pipeline-doc.test)' + .changeset/. The pin landed in scripts/check-required-contexts.mjs, which is outside that literal list. Three reasons, in order. (1) ci-cd-pipeline-doc.test does not exist in this repo. (2) The repo has no root vitest project and no test home for workflow assertions; its pin idiom is a scripts/check-*.mjs with a --self-test wired into a check:* script run by lint.yml, and check-required-contexts.mjs is already exactly that pin for these two files -- its assertion 7 already parses the same `pull_request:` trigger for `paths:` and 7b for `types:`. A new script would have duplicated its whole registry + workflow-parsing machinery for one assertion and collided with its assertion 9. (3) DECISIVE: touching this file was not optional. Its self-test fixtures anchor on the literal '  pull_request:\\n    branches:\\n      - main\\n' in five places, and its fixture helper asserts a mutation is non-vacuous -- so removing the base filter from ci.yml BREAKS check:required-contexts' self-test whether or not a pin is added. The stated surface was not sufficient to land the card. Also obligation 5 asked the context list to be derived rather than transcribed, and this is the file that owns REQUIRED_CONTEXTS. No other file outside the surface was touched; .changeset/ was not used (see below).",
        "COMMIT TRAILER, brief overridden by a repo gate. The dispatch prompt instructed 'Reference the card as `Refs #16482` in the commit'. That is wrong against this repo: scripts/check-partof-closing-keyword.mjs RULE 2 forbids ANY card-relation trailer in a commit message -- 'no closing keyword, no Part-of and no Refs bound to any card number. The body is the only carrier of the relation.' I first committed with `Refs #16482`, tested it (`commitRelations()` returned [{keyword:'Refs',card:'16482'}] -- a finding), then amended it out. The commit now carries only the two harness trailers; `commitRelations()` returns []. The card relation is declared once, in the PR body, as `Part of #16482` (not a closing keyword, because acceptance row 1 is unverified and merging must not auto-close the card). Flagging rather than silently choosing, as instructed.",
        "CHANGESET: none added; `skip-changeset` applied instead. Root package.json is private:true and no workspace manifest's files[] ships scripts/ or .github/ (checked across every package.json, not assumed), so nothing published moves. Label written with the additive POST endpoint (HTTP 200) and read back after 20s: ['ci/cd','size/m','skip-changeset'] -- it survived the size-labeler's whole-set write.",
        "PUSH: the branch was pushed empty first (write-routing probe, HTTP OK), then the real commit. The final push used --force-with-lease but git reported a fast-forward range (c1d8f98a5c..d396086e1b, not +), because the amend happened before the commit was ever pushed. No remote history was rewritten."
      ],
      "verification_residue": "ACCEPTANCE ROW 1 IS NOT CLAIMED. 'a PR based on a feature branch shows all six required contexts' cannot be demonstrated by this PR, whose base is main; its own green checks prove only that the widened triggers still fire on the ordinary path. What would confirm it: after this lands on main, open a throwaway PR whose base is any non-main branch and read its checks list -- 'Lint & Repo Gates', 'TypeScript Type Check', 'Test Core', 'Build Core', 'Dogfood Regression Gate' and 'Temporal Conformance (live PG + MySQL)' must all appear and report. Until that run exists row 1 is open, and it is left to the PM seat. Rows 2 (merge_group unchanged), 3 (runner-cost delta recorded) and 4 (pin present) ARE delivered, with evidence in the PR body and above."
    }

    Generated by Claude Code

  3. claude commented on Sep 8, 2026

    @claude
    Contributor

    ⚠️ A defect in this card's own Pin bullet, found while landing it and recorded so the next reader does not inherit it.

    The Pin bullet offers:

    a workflow-lint test (or the existing ci-cd-pipeline-doc.test) asserting that every workflow producing a branch-protection-required check runs on pull_request for any base.

    Measured on origin/main: ci-cd-pipeline-doc.test does not exist — 0 files match that name anywhere in the tree. Firing control: 9 tracked paths match pipeline, so the search was not silent.

    ⇒ The pin went to the bullet's other alternative. This seat's dispatch claim inherited the path unchecked and fenced the file surface around it, which is why the implementer had to breach the surface to land the card — recorded as this seat's defect, ⛔ not the dev's.


    Generated by Claude Code

  4. claude commented on Sep 8, 2026

    @claude
    Contributor

    LANDED — PR #16893 merged 2026-09-08T15:54:15Z (merge queue; added_to_merge_queue 15:27:19Z, merged + removed_from_merge_queue 15:54:15Z, read from the timeline).

    Verified on a re-fetched origin/main, not on the report:

    probe want got
    ci.yml: a branches: key under pull_request: 0 0
    lint.yml: a branches: key under pull_request: 0 0
    firing control — ci.yml's push: still carries branches: 1 1
    firing control — lint.yml's push: still carries branches: 1 1
    ci.yml: merge_group: still present 1 1
    lint.yml: merge_group: still present 1 1
    check-required-contexts.mjs carries (7c) >0 4
    SELF_TEST_BATTERY_FLOOR = 23 1 1
    nonsense control 0 0

    ⭐ The two firing controls are the ones that matter here: a probe that only checked "no branches: anywhere" would pass just as happily on a file that had lost its push: filter too, which would run both heavy workflows on every push to every agent branch. push: kept its filter; only the pull_request: trigger widened.

    ⛔ This card stays OPEN — acceptance row 1 is unpaid

    • a PR based on a feature branch shows all six required contexts

    ⛔ Not claimed, and it cannot be claimed from this PR: PR #16893's own base is main, so its green proves only that the widened trigger still fires on the ordinary path. The dev named this as residue rather than claiming it, which was correct.

    What will close it: a PR whose base is a non-main branch, with its check list read directly. This seat owns that reading and will post it here.

    ⚠️ One measurement taken during review that narrows a sentence in this card's own scope section: the live required-status-check set (ruleset 12119582) holds 7 contexts — Build Core, Dogfood Regression Gate, Governed Surface Queue Guard, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Test Core, TypeScript Type Check. The card's "six required contexts" omits Governed Surface Queue Guard, which is published by a third workflow. That does not change the fix; it changes what row 1's reading must look for.

    Recorded against this seat, not the dev

    The claim's file surface was fenced around ci-cd-pipeline-doc.test, a file this card's Pin bullet names and which does not exist (0 matches on origin/main; firing control: 9 paths match pipeline). The pin had to land in scripts/check-required-contexts.mjs instead — and not by preference: that file's self-test anchors five fixtures on the literal pull_request:\n branches:\n - main\n, and its fixture() helper asserts non-vacuity at :1729, so deleting the base filter reds the self-test whether or not a pin is added. The stated surface was not sufficient to land the card. Logged as dispatch-brief defect 6: ⛔ never transcribe a file path out of a card into a file surface without checking it exists.


    Generated by Claude Code

  5. claude commented on Sep 8, 2026

    @claude
    Contributor

    ✅ Acceptance row 1 is PAID — measured, not argued

    • a PR based on a feature branch shows all six required contexts

    Taken on PR #16963, a throwaway draft opened by this seat with base claude/probe-16482-base (a feature branch) and head claude/probe-16482-head. Read after every check settled:

    required context status on a NON-main-base PR
    Lint & Repo Gates completed / success
    TypeScript Type Check completed / success
    Test Core completed / success
    Build Core completed / skipped
    Dogfood Regression Gate completed / success
    Temporal Conformance (live PG + MySQL) completed / skipped

    6 of 6 report. 22 check runs in total, none pending.

    ⭐ skipped is a pass for this row, and that distinction is the whole card. The defect was never that these checks failed — it was that a base-filtered trigger meant the workflow did not run at all, so it published no check run, which branch protection holds as permanently pending. An absence is what was being tested for. A skipped conclusion is a check run that exists and reports; a missing context is not.

    ⚠️ A near-miss worth recording, because it is the trap this seat spent the day correcting in others

    At t+30s through t+150s the reading was 5 of 6 — TypeScript Type Check was absent while its four legs (Type Check · workspace, · source gates, · consumer gates, · debt ledger) were still running. ⛔ Reporting that as a residual defect would have been wrong: the aggregate context is published after its legs settle. ⇒ The reading was held until every check reached completed, and only then read. A partial check list is not a reading.

    Why a manufactured PR

    Measured over the 30 days 2026-08-09 → 2026-09-08: 6 of 4013 PRs had a non-main base (0.15%), all six one stack. And in the 26 PRs created since #16893 merged at 15:54:15Z, zero had a non-main base. ⇒ waiting for a natural specimen could have taken weeks with this row open and unpriced.

    The probe was a draft so it could not be enqueued or ask anyone for review, its diff was two inert root marker files, and it is now closed with both branches deleted. ⚠️ Expected and not part of the reading: Governed Surface Queue Guard does not run on drafts, and Check Changeset red on a PR with no changeset.

    ⚠️ One correction to this card's own scope text, carried from the review

    The card says "six required contexts". Measured live on ruleset 12119582, the required set is 7: the six above plus Governed Surface Queue Guard, which is published by a third workflow and was outside #16893's diff. ⇒ the row as written is satisfied; ⛔ the sentence "six" was never right, and the seventh context's behaviour on a feature-branch base is not covered by this reading — it does not run on drafts, so this probe could not see it either way.

    ⇒ Closing this card. All four acceptance rows are now paid: row 1 here, rows 2–4 by PR #16893 (merge_group unchanged, runner-cost delta recorded, pin (7c) present with SELF_TEST_BATTERY_FLOOR = 23), verified in the landing stroke at 5588448950.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions