Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/scripts/pr-labeler.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ function detectTypeLabelFromTitle(title) {
return null;
}

/**
* Type from the PR's own commits, for titles the title matcher cannot classify.
*
* A PR titled `stack 3/5: carry six contributor bug fixes` fails the
* conventional regex (the `3/5` sits between the word and the colon) and then
* reaches the sentence-case fallback, which extracts `stack`. That has no entry
* in PREFIX_TO_LABEL, so the sync skips — and a skip is not a failure, so the
* `label` check stays green while the PR carries no type label at all. The
* commits underneath are conventional (`fix(codex): ...`), so they can answer
* the question the title cannot.
*
* `chore` is supporting, not competing. `test:`, `ci:`, `chore:`, `style:`,
* `refactor:`, and `build:` all map to it, and none of them says what a PR is
* FOR. Requiring unanimity would abstain on almost every real PR: #955 is four
* `fix(codex):` commits plus one `test(codex):`, and it is a bug fix.
*
* Anything still ambiguous after that (`fix:` alongside `feat:`) stays
* unlabeled rather than guessed.
*/
function detectTypeLabelFromCommits(messages) {
const types = new Set();
for (const message of Array.isArray(messages) ? messages : []) {
const detected = detectTypeLabelFromTitle(String(message || "").split("\n")[0]);
if (detected) types.add(detected);
}
if (types.size > 1) types.delete("chore");
return types.size === 1 ? [...types][0] : null;
}

/**
* True when a human (any non-bot actor) has ever labeled or unlabeled a managed
* type label on this PR. Mirrors issue-quality's sticky maintainerOverride:
Expand Down Expand Up @@ -105,7 +134,11 @@ function planTypeLabelSync(input) {
return { skip: true, reason: "human-override" };
}

const detected = detectTypeLabelFromTitle(title);
// The title is authoritative when it classifies. The commits only answer for
// titles it cannot (`stack 3/5: ...`), so a well-formed title is never
// overridden by what happens to be committed under it.
const detected =
detectTypeLabelFromTitle(title) ?? detectTypeLabelFromCommits(input?.commitMessages);
if (!detected) {
return { skip: true, reason: "no-prefix" };
}
Expand All @@ -121,6 +154,7 @@ module.exports = {
TYPE_LABELS,
BOT_ACTORS,
detectTypeLabelFromTitle,
detectTypeLabelFromCommits,
hasHumanTypeLabelOverride,
planTypeLabelSync,
};
110 changes: 110 additions & 0 deletions .github/scripts/pr-labeler.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
detectTypeLabelFromTitle,
detectTypeLabelFromCommits,
hasHumanTypeLabelOverride,
planTypeLabelSync,
TYPE_LABELS,
Expand Down Expand Up @@ -145,6 +146,115 @@ describe("planTypeLabelSync", () => {
});
});

describe("detectTypeLabelFromCommits", () => {
it("reads the type from unanimous commits", () => {
assert.equal(
detectTypeLabelFromCommits([
"fix(usage): price long-context requests at the published long rate",
]),
"bug",
);
});

it("treats chore as supporting, not competing (PR #955 shape)", () => {
// Four `fix(codex):` commits plus one `test(codex):`. Requiring unanimity
// would abstain here, and on almost every real PR — nearly every
// substantial change carries a test or chore commit alongside its fix.
assert.equal(
detectTypeLabelFromCommits([
"fix(codex): probe reset-derived cooldowns without waiting to be selected",
"fix(codex): fail closed on an unrecognized plan",
"fix(codex): classify prolite as a weekly plan",
"fix(codex): share one window rule instead of a plan allowlist",
"test(codex): assert the window rule in literals",
]),
"bug",
);
});

it("keeps chore when nothing else competes", () => {
assert.equal(
detectTypeLabelFromCommits(["ci: pin an action", "test: add a case"]),
"chore",
);
});

it("abstains on a genuine mix of fix and feat", () => {
assert.equal(
detectTypeLabelFromCommits(["fix(a): repair x", "feat(b): add y"]),
null,
);
});

it("reads only the first line of a multi-line commit message", () => {
// A body line starting with `feat:` must not vote.
assert.equal(
detectTypeLabelFromCommits([
"fix(a): repair x\n\nfeat: this is prose in the body, not a type",
]),
"bug",
);
});

it("returns null for absent or unusable input", () => {
assert.equal(detectTypeLabelFromCommits([]), null);
assert.equal(detectTypeLabelFromCommits(undefined), null);
assert.equal(detectTypeLabelFromCommits(["", null]), null);
});
});

describe("planTypeLabelSync commit fallback", () => {
it("labels a stack PR whose title carries no type", () => {
// `stack 3/5:` fails the conventional regex (the `3/5` sits between the
// word and the colon), reaches the sentence-case fallback, which extracts
// `stack` — a word with no PREFIX_TO_LABEL entry. The sync used to skip
// here, and a skip is not a failure, so the `label` check stayed green
// while all four stack PRs carried no type label.
const plan = planTypeLabelSync({
title: "stack 3/5: carry six contributor bug fixes with authorship intact",
currentLabels: [],
events: [],
commitMessages: [
"fix(kiro): round-trip the redactedContent reasoning blob",
"fix(responses): close passthrough streams at terminal events",
],
});
assert.deepEqual(plan, { skip: false, detected: "bug", add: "bug", remove: [] });
});

it("does not let commits override a title that already classifies", () => {
const plan = planTypeLabelSync({
title: "feat(providers): add a preset",
currentLabels: [],
events: [],
commitMessages: ["fix(a): repair x", "fix(b): repair y"],
});
assert.equal(plan.detected, "enhancement");
});

it("still skips when neither the title nor the commits classify", () => {
const plan = planTypeLabelSync({
title: "stack 1/5: triage the open issue surface",
currentLabels: [],
events: [],
commitMessages: ["wip", "more wip"],
});
assert.deepEqual(plan, { skip: true, reason: "no-prefix" });
});

it("still honours a human override before consulting commits", () => {
const plan = planTypeLabelSync({
title: "stack 2/5: price long-context requests",
currentLabels: ["enhancement"],
events: [
{ event: "labeled", label: { name: "enhancement" }, actor: { login: "a-human" } },
],
commitMessages: ["fix(usage): price long-context requests"],
});
assert.deepEqual(plan, { skip: true, reason: "human-override" });
});
});

describe("pr-labeler workflow", () => {
const workflowPath = path.join(__dirname, "../workflows/pr-labeler.yml");
const workflow = fs.readFileSync(workflowPath, "utf8");
Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@ name: Cross-platform CI

on:
pull_request:
branches: [main, dev]
# No base-branch filter on purpose. GitHub matches `branches:` against the
# BASE ref, so `[main, dev]` silently excluded stacked child PRs — whose
# base is another open PR's head branch, an intentional review workflow per
# AGENTS.md that `enforce-target` already exempts from the wrong-base gate.
Comment on lines +5 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the workflow map for the broadened trigger

Removing the base-branch filter makes this workflow run for qualifying PRs against any base, but structure/06_docs-and-release.md:43 still says Cross-platform CI runs only for pull requests to main or dev. This leaves the repository's maintainer workflow map contradicting the actual trigger and the newly updated public guide; update that entry to describe the unfiltered pull_request trigger and its path filtering.

Useful? React with 👍 / 👎.

# The #951-#955 stack merged with `enforce-target`, `label`, and
# `react-doctor` as its only check-runs: no test job ever queued for 24
# changed files under `src/`.
#
# An allowlist cannot express "base is another PR's head" — stacked bases
# carry contributor prefixes (`fix/`, `feat/`, `agent/`) as readily as
# `codex/`, and contributor stacks need CI most. `paths:` below is the real
# scope gate, same shape as issue-quality-tests.yml. Safe to widen here
# because this workflow is `pull_request` (not `pull_request_target`),
# declares `contents: read`, and reads no secrets.
#
# `push:` stays pinned to the integration lines: it gates the release path,
# and this trigger already covers review.
paths:
- "src/**"
- "bin/**"
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/pr-labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,18 @@ jobs:
owner, repo, issue_number: pr, per_page: 100,
});

// Titles that carry no recognisable type (e.g. `stack 3/5: ...`)
// fall back to the PR's commits, which stay conventional even when
// the title does not. Covered by the existing `contents: read`.
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner, repo, pull_number: pr, per_page: 100,
});

const plan = planTypeLabelSync({
title: liveTitle,
currentLabels: currentLabels.map((label) => label.name),
events,
commitMessages: commits.map((commit) => commit.commit?.message || ''),
});

if (plan.skip) {
Expand Down
119 changes: 119 additions & 0 deletions devlog/_plan/260804_stacked_pr_ci/000_scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 000 — Stacked PR CI: scope and evidence

Unit: `260804_stacked_pr_ci`
Class: C3 (two workflow-surface defects, cross-cutting, needs durable audit)

## The defect, in one sentence

A stacked child pull request — one whose base is another **open** PR's head
branch — runs no test CI at all, and gets no type label.

## Evidence

`AGENTS.md` calls stacked child PRs an intentional review workflow:

> Stacked child pull requests that target another **open** PR's head branch are
> an intentional review workflow, not an alternate integration line.

`enforce-pr-target.yml` implements that intent: it detects a stacked base by
listing open PRs and matching `other.head.ref === pr.base.ref`, then skips the
wrong-base gate. So the repository deliberately supports this shape.

Observed on the #951–#955 stack (checked 2026-08-04, `gh api
repos/lidge-jun/opencodex/commits/<head>/check-runs`):

| PR | base | check-runs present |
| --- | --- | --- |
| #952 | `codex/bug-stack-plan` | `enforce-target`, `label`, `react-doctor` |
| #953 | `codex/908-long-context-pricing` | `enforce-target`, `label`, `react-doctor` |
| #954 | `codex/carry-contributor-bugfixes` | `enforce-target`, `label`, `react-doctor` |
| #955 | `codex/545-classifier-thinking-disabled` | `enforce-target`, `label`, `react-doctor` |

No `ci`, no `gates`, no `test 1/4`–`test 4/4`. The stack carried 24 changed
files under `src/` and 748 added lines with **zero** CI verification history.

Labels on all four: empty.

## Root cause 1 — the `ci.yml` branch filter

`.github/workflows/ci.yml`:

```yaml
on:
pull_request:
branches: [main, dev]
paths: [...]
```

GitHub evaluates `branches:` against the PR's **base** ref. A stacked child's
base is `codex/bug-stack-plan`, which is neither `main` nor `dev`, so the
workflow is never queued. The other PR workflows have no `branches:` filter
(`enforce-pr-target.yml`, `pr-labeler.yml`, `react-doctor.yml`), which is
exactly why those three checks appear and the test jobs do not.

The filter is not wrong on its own — it exists to keep CI off unrelated base
branches. It is wrong that it has no exception for the one alternate base shape
the repository explicitly supports.

### The fix, after audit

The first draft narrowed the filter to `[main, dev, "codex/**"]`. The audit
killed it: open PR head refs are `codex/` (14) **and** `fix/` (4), `feat/` (3),
`agent/` (3), `split/`, `ingw/`. Any of those can become a stacked base, and a
contributor stack is the case that most needs CI. An allowlist cannot express
"base is another open PR's head".

So the filter goes, and `paths:` — untouched — remains the scope gate. That is
already this repository's other pattern: `issue-quality-tests.yml` runs
`pull_request` with `paths:` and no `branches:`. Details in `010`.

## Root cause 2 — the labeler's title contract

`.github/scripts/pr-labeler.cjs` → `detectTypeLabelFromTitle()` recognises two
forms:

1. conventional commit — `^([a-zA-Z]+)(\([^)]*\))?!?\s*:`
2. sentence-case fallback — `^([A-Za-z]+)\s+\S`

Verified locally against the real stack titles:

```
planTypeLabelSync({title: "stack 1/5: triage the open issue surface..."})
-> { skip: true, reason: "no-prefix" }
```
Comment on lines +80 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add languages to all fenced literal-output blocks.

markdownlint-cli2 reports MD040 at each site. Add text after each opening fence.

  • devlog/_plan/260804_stacked_pr_ci/000_scope.md#L80-L83: change the opening fence to ````text``.
  • devlog/_plan/260804_stacked_pr_ci/010_ci_trigger.md#L31-L33: change the opening fence to ````text``.
  • devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md#L15-L18: change the opening fence to ````text``.
  • devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md#L24-L27: change the opening fence to ````text``.
  • devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md#L56-L59: change the opening fence to ````text``.
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 80-80: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

📍 Affects 3 files
  • devlog/_plan/260804_stacked_pr_ci/000_scope.md#L80-L83 (this comment)
  • devlog/_plan/260804_stacked_pr_ci/010_ci_trigger.md#L31-L33
  • devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md#L15-L18
  • devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md#L24-L27
  • devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md#L56-L59
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260804_stacked_pr_ci/000_scope.md` around lines 80 - 83, Add the
text language identifier to each fenced literal-output block: update the opening
fences at devlog/_plan/260804_stacked_pr_ci/000_scope.md lines 80-83,
devlog/_plan/260804_stacked_pr_ci/010_ci_trigger.md lines 31-33, and
devlog/_plan/260804_stacked_pr_ci/020_labeler_and_docs.md lines 15-18, 24-27,
and 56-59. No other content changes are needed.

Source: Linters/SAST tools


`stack 1/5:` fails the conventional regex (the `1/5` sits between the word and
the colon) and is then caught by the sentence-case fallback, which extracts
`stack`. `PREFIX_TO_LABEL` has no `stack` key, so the lookup returns `null` and
the sync skips. The `label` check still reports success — a skip is not a
failure — which is why this stayed invisible.

This is **not** a stacked-PR bug. It is a title-vocabulary bug that the stack
happened to expose: any PR titled with an unrecognised prefix word is silently
unlabeled. The stack shape and the label gap are independent defects that share
one symptom report.

## Non-goals

- Merging #952–#955. The user owns that; this unit never touches those PRs.
- Changing `src/` runtime code.
- Promotion to `main`/`preview`, releases, tags.

## Promotion caveat that must reach the docs

`pr-labeler.yml` and `enforce-pr-target.yml` run on `pull_request_target`, which
GitHub always loads from the repository **default branch** (`main`). Landing a
labeler change on `dev` does not change live behavior until it is promoted. The
labeler file already carries this comment; the contributor docs do not say it.
`ci.yml` runs on `pull_request` and is read from the PR's merge ref, so the CI
trigger fix takes effect as soon as it is on the base branch being targeted.

## Work-phase map (dependency-ordered)

| Phase | Doc | Depends on |
| --- | --- | --- |
| 1 | `010_ci_trigger.md` | — |
| 2 | `020_labeler_and_docs.md` | 010 (shares the test file) |

Phase 2 touches `tests/ci-workflows.test.ts` after phase 1 has added its block,
so it must run second to avoid re-resolving the same region twice.
Loading
Loading