Skip to content

feat(actions): add pr-contract composite and carry-forward mode to ci-status - #550

Merged
kyle-sexton merged 6 commits into
mainfrom
ci-perf/phase-3-pr-contract
Sep 4, 2026
Merged

feat(actions): add pr-contract composite and carry-forward mode to ci-status#550
kyle-sexton merged 6 commits into
mainfrom
ci-perf/phase-3-pr-contract

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

No related issue: melodic-software/github-iac#396 tracks Phase 3 of the ci-perf program

Summary

Phase 3.1 of the CI/CD performance program: collapse the four required status
checks every enrolled repository carries today (ci-status, pr-title,
do-not-merge, pr-issue-linkage) into one, ci-status, so a title edit, a
label change, or a body edit costs a single cheap job instead of three
pull_request_target workflows plus a full lane run.

Two composite actions change here. pr-contract is new and carries the whole
pull-request contract in one step. ci-status gains an extracted runner, its
first test harness, and a carry-forward branch that lets a contract-only event
reuse the lane verdict a full run already recorded.

Fix

  • New .github/actions/pr-contract/ (action.yml, run.sh, run.test.sh,
    README.md). The Conventional Commits title check and the do-not-merge
    label check fail the step; the issue-linkage check is advisory by default —
    a warning, one upserted comment carrying the marker
    <!-- pr-contract:linkage -->, and the needs-issue-linkage label, with the
    exit code unchanged. linkage-mode: enforce turns it into a gate. Semantics
    are ported from the semantic-pr, do-not-merge-gate and pr-issue-linkage
    reusables, including the negated-closer rule and the Refs: / Relates to:
    non-closing marker from ci-workflows#544. Those reusables stay in place; this
    is additive.
  • .github/actions/ci-status/: the inline aggregation moves into run.sh
    unchanged, gains run.test.sh, and gains a carry-forward branch. In full mode
    the action records the lane verdict as a commit status under ci-lanes on the
    head SHA; on edited, labeled and unlabeled it skips aggregation and
    passes only when that status is success on the same SHA. The status write is
    load-bearing: a write still refused after three retries fails the run naming
    the missing statuses: write permission, because the carry-forward branch
    reads nothing else.
  • Dogfood in this repository's own ci.yml: edited, labeled and unlabeled
    join on.pull_request.types; cancel-in-progress now excludes those three
    actions so a contract-only run queues behind the full run it reads from
    instead of cancelling it; every job except ci-status carries the gate
    expression; and the ci-status job gains pull-requests: write,
    statuses: write, and the two composite steps in order, the second under
    if: ${{ !cancelled() }} so a failing contract step never skips the status
    write.
  • The selector-contract lane runs both new harnesses; the shfmt lane covers
    both new action directories.
  • pr-title.yml, do-not-merge.yml and pr-issue-linkage-self.yml are
    untouched; Phase 3.4 retires them after the org ruleset changes.

Verification

  • bash .github/actions/pr-contract/run.test.shAll pr-contract tests passed. (43 named cases: every allowed type including security, unknown
    type, missing colon, empty subject, the breaking-change marker, scope under
    both require-scope values, the label gate, all nine closing-keyword forms,
    cross-repository closers, both no-issue markers, Refs: accepted and bare
    Refs rejected, negated and contracted-negated closers, the not only
    exemption, keywords masked inside HTML comments / fenced blocks / inline code
    spans, each missing section, an empty section, a nested subsection, exempt
    authors, comment create-then-edit upsert, 403 on both writes, enforce mode,
    and the metadata contract).
  • bash .github/actions/ci-status/run.test.shAll ci-status tests passed.
    (17 named cases covering both modes, the retry ladder, and the preserved
    treat-skipped-as messages).
  • Discrimination proof: with the body masking removed, three pr-contract cases
    go red; with the carry-forward branch removed, six ci-status cases go red.
  • shellcheck --rcfile .shellcheckrc and shfmt -d -i 2 -ci clean on all four
    new scripts; actionlint .github/workflows/ci.yml clean;
    zizmor --persona regular .github/ adds no finding class this repository does
    not already carry (the new local uses: produces the same self-repository
    help the existing ci-status step does); markdownlint-cli2 clean on both
    changed markdown files.
  • yq over .jobs confirms all 26 non-ci-status jobs carry the
    contract-only gate expression and ci-status alone does not.
  • This pull request is the live proof: its own run executes pr-contract inside
    ci-status, a title edit re-runs only ci-status, and the do-not-merge
    label flips it red and its removal flips it green.

Related

Refs melodic-software/github-iac#396
Refs melodic-software/github-iac#378

…-status

Phase 3.1 of the CI/CD performance program: collapse the four required
status checks every enrolled repository carries today into one,
`ci-status`, so a title edit, a label change, or a body edit costs one
cheap job instead of three `pull_request_target` workflows plus a full
lane run.

`pr-contract` is new. One step carries the Conventional Commits title
check and the `do-not-merge` label check (both gate) plus the issue
linkage check (advisory by default: a warning, one upserted comment
carrying the marker `<!-- pr-contract:linkage -->`, and the
`needs-issue-linkage` label, exit code unchanged). Semantics are ported
from the `semantic-pr`, `do-not-merge-gate` and `pr-issue-linkage`
reusables, including the negated-closer rule and the `Refs:` /
`Relates to:` non-closing marker from #544. Those reusables are
untouched; this is additive until Phase 3.4 retires their callers.

`ci-status` gains an extracted `run.sh` with behaviour unchanged, its
first test harness, and a carry-forward branch. Full mode aggregates as
before and then records the verdict as a commit status under `ci-lanes`
on the head SHA; on `edited`, `labeled` and `unlabeled` it skips
aggregation and passes only when that status is `success` on the same
SHA. A commit status, not the check-run list, is the carried signal
because a check run cannot say which event produced it, so a chain of
contract-only runs could otherwise self-certify. The status write is
load-bearing: a write still refused after three retries fails the run
naming the missing `statuses: write` permission.

This repository dogfoods both in its own `ci.yml`: the three
contract-only trigger types are added, `cancel-in-progress` excludes
them so a contract-only run queues behind the full run it reads from,
every job except `ci-status` carries the gate expression, and the
`ci-status` job gains `pull-requests: write`, `statuses: write` and the
two composite steps in order with the second under `!cancelled()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T14:12:20.342797Z db7f749 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 3s —— View job


Review complete

  • Gather context (PR diff, files, CLAUDE.md rules)
  • Understand request (code review via /review:code-review)
  • Review pr-contract action (action.yml, run.sh, run.test.sh, README)
  • Review ci-status changes (action.yml, run.sh, run.test.sh)
  • Review ci.yml dogfooding changes
  • Review README.md changes
  • Post findings as inline comments + summary

One inline finding posted, high confidence:

Fork-originated PRs can never pass the new required ci-status check. ci.yml triggers on plain pull_request (not pull_request_target), so for a PR from a fork, GitHub forces GITHUB_TOKEN to read-only regardless of the job's declared permissions: block. pr-contract's writes are designed to degrade gracefully on a read-only token (best-effort), but the new ci-status commit-status write (.github/actions/ci-status/run.sh:171-189) is explicitly load-bearing — after 3 retries it hard-fails the job. Since every pull_request action type is full-mode, and carry-forward mode itself depends on a prior successful full-mode write, a fork PR could never produce a green ci-lanes status, independent of whether its lanes actually passed. The sibling reusable this composite ports logic from (pr-issue-linkage.yml) already documents needing pull_request_target for exactly this class of problem. Details and a suggested direction are in the inline comment on .github/actions/ci-status/run.sh.

Everything else held up well under review:

  • The pr-contract Conventional-Commits/label/issue-linkage logic (including the negation detection, HTML-comment/fence/inline-code masking, and section parsing in run.sh) is intricate but the 43-case test suite (run.test.sh) exercises the tricky edges convincingly (negated + contracted-negated closers, the "not only" exemption, cross-repo closers, nested subsections, exempt-author list-splitting, comment upsert/create, 403-refused writes, enforce mode).
  • The ci-status carry-forward branch, retry ladder, and treat-skipped-as policy are similarly well covered by its own 17-case suite, including the commit-status-context filtering and API-failure-closed cases.
  • ci.yml's contract-only gating (edited/labeled/unlabeled excluded from cancel-in-progress, every lane job gated, ci-status: if: always() preserved) is consistent and the concurrency-queueing reasoning in the comments checks out.
  • Permission scoping (pull-requests: write for pr-contract's comment/label, statuses: write for ci-status's commit status) matches what each composite actually does.

I was not able to execute the test suites directly in this sandbox (shell execution required approval that wasn't available), so the above is based on static reading of run.sh/run.test.sh, not a live run — the PR's own verification section states both suites pass.
· Branch

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 57s —— View job


Claude finished the security review

  • Gather context (diff, changed files, CLAUDE.md rules)
  • Review .github/actions/pr-contract/ (run.sh, action.yml)
  • Review .github/actions/ci-status/ (run.sh, action.yml)
  • Review .github/workflows/ci.yml dogfooding changes
  • Post findings

Findings

1. (High) The consolidated gate can be neutralized by editing the PR's own branch — pull_request vs pull_request_target

ci.yml wires the new pr-contract/ci-status gate under a plain pull_request trigger:
ci.yml#L6-L12, and the ci-status job invokes the local composite directly from the checked-out PR branch:
ci.yml#L690-L716.

For pull_request (unlike pull_request_target), GitHub runs the workflow as defined on the PR's own branch, including any local composite (uses: ./.github/actions/pr-contract, uses: ./.github/actions/ci-status). A PR author can therefore edit .github/actions/pr-contract/run.sh, .github/actions/ci-status/run.sh, or ci.yml itself in their own PR — e.g. make pr-contract unconditionally emit pass/exit 0 — and the required ci-status check will report green regardless of the actual title, do-not-merge label, or issue-linkage state, with no code execution or elevated token needed to pull it off.

This is exactly the tampering vector this repo's own predecessor reusable already documents and defends against:
pr-issue-linkage.yml#L18-L21"The caller must use pull_request_target (not pull_request): this runs the workflow definition from the base branch, not the PR head, so a fork/head cannot tamper with this gate." The same rationale applies to do-not-merge.yml and semantic-pr.yml, both also pull_request_target-only.

Failure scenario: a contributor opens a PR with a non-conventional title and a do-not-merge label attached, edits .github/actions/pr-contract/run.sh in the same PR to short-circuit and always exit 0, and the now-sole required ci-status check goes green — bypassing every check pr-contract exists to enforce.

Today this is partly mitigated because pr-title.yml / do-not-merge.yml / pr-issue-linkage-self.yml remain active (unchanged, still pull_request_target), so a bypass of the new path doesn't yet bypass enforcement outright. But per the PR body, Phase 3.4 explicitly retires those once this pattern is trusted — at which point this would become the only enforcement and the bypass becomes complete. Worth resolving (or at least explicitly flagging as a blocking follow-up before Phase 3.4) rather than deferring, since this repo's own composites are meant to be the template "every enrolled repository" adopts.

2. (Medium) Fork PRs get a forced-read-only GITHUB_TOKEN, and ci-status's status write is load-bearing

GitHub always forces GITHUB_TOKEN to read-only for pull_request runs triggered from a forked repository, regardless of the permissions: block (ci.yml#L700-L704 declares pull-requests: write / statuses: write). pr-contract's writes degrade gracefully via best_effort (fine), but ci-status's commit-status write is explicitly not best-effort — it fails the job after three retries naming the missing permission:
ci-status/run.sh#L153-L189.

For a genuine fork-originated PR, that write will be refused every time, so every full-mode run fails the (soon-to-be sole) required check even when every real lane passed — and every later edited/labeled/unlabeled carry-forward run then also fails, since there is no successful ci-lanes status to read back. Worth validating end-to-end against an actual fork-originated PR (not a same-repo branch, where the token gets its declared permissions) before relying on this as the sole gate.

3. (Low confidence — worth confirming) Fenced/inline-code masking in the issue-linkage analyzer may create a blind spot vs. GitHub's real closing-keyword scan

analyze_body deliberately masks fenced code, indented code, and inline code spans before scanning for closing keywords, so a closing keyword written only inside a code block does not satisfy the linkage contract — this is intentional and tested (run.test.sh#L407-L411, masking logic at run.sh#L310-L341).

The negated-closer rule in the same file is built on the premise that "GitHub's own parser is negation-blind and closes the issue on merge regardless of the disclaimer" (run.sh#L153-L165) — i.e., GitHub's real scan is a dumb text match, indifferent to surrounding prose. If it's similarly indifferent to Markdown code fencing (unconfirmed here), a body with Closes #123 written only inside a fenced block would be reported by this gate as "missing a closing keyword" (satisfiable with Refs:/No related issue:) while GitHub still silently auto-closes the issue on merge — the exact outcome the negation rule exists to prevent, just reached through fencing instead of negation. Recommend confirming GitHub's actual behavior here; if it doesn't respect fencing, the closing/negation scan should run against the unmasked body (fencing should only suppress the "section" satisfiability check, not hide a live closing reference from disclosure).


No issues found with the injection-safety of the PR content handling itself: both composites correctly read PR title/body/labels via gh api + jq into files/variables rather than splicing untrusted content into the run: block, matching the pattern documented in the existing reusables.
·

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db7f749a5d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/actions/ci-status/run.sh
Comment thread .github/actions/ci-status/run.sh
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Last security-reviewed head: 8e359c2d2de9c1162d36d24ac4bcfa2262131828. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

Three lane failures from the first run of this branch, all in the new
files:

- shfmt: the root EditorConfig sets no `switch_case_indent`, so the CI
  lane formats `case` arms unindented. Reformat both runners and both
  harnesses with EditorConfig-driven `shfmt -w` rather than the `-ci`
  flag used locally.
- comment-hygiene: a comment in the pr-contract harness carried a
  closing keyword next to a tracker reference, which the scanner reads
  as a deferred-work marker. Reword it.
- ci-fanout-consolidation: the contract test pins ci.yml's
  `cancel-in-progress` expression verbatim, and the contract-only gate
  extends it. Update the assertion, keeping the #122 burst-collapse
  guard it exists to protect, and add three assertions that lock the
  rest of the dogfood shape: the six trigger types, the gate on every
  job except `ci-status`, and the `ci-status` job's permissions plus its
  two steps in order with the second under `!cancelled()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 30s —— View job


Security review complete

  • Gather context (PR diff, changed files, CLAUDE.md rules)
  • Review .github/actions/pr-contract/ (action.yml, run.sh)
  • Review .github/actions/ci-status/ (action.yml, run.sh)
  • Review .github/workflows/ci.yml dogfooding changes
  • Cross-check prior review findings against current head SHA
  • Post findings

I re-ran a full security review against the current head, cd94cb2. The second commit (cd94cb2, "satisfy the shfmt, comment-hygiene and consolidation lanes") is a pure formatting/test-hardening diff over db7f749 — it touches indentation in ci-status/run.sh and pr-contract/run.sh and adds assertions to ci-fanout-consolidation.test.cjs. It does not touch ci.yml, either action.yml, or the trust-boundary logic. Both structural findings from the earlier reviews on this PR are still present at this head and unaddressed:

1. (High) pull_request (not pull_request_target) lets a PR author neutralize the sole required check from their own branch

ci.yml#L3-L12 triggers on plain pull_request, and ci.yml#L710-L717 invokes the local composites (uses: ./.github/actions/pr-contract, uses: ./.github/actions/ci-status) straight from the checked-out PR branch. Unlike pull_request_target, pull_request runs the workflow and every local action it references as defined on the PR head — so a PR author can edit run.sh in either composite (or ci.yml itself) in the same PR to force pr-contract to exit 0 unconditionally, and the now-sole required ci-status check goes green regardless of title, do-not-merge label, or issue-linkage state. This is the exact tampering vector pr-issue-linkage.yml's own header comment documents and defends against by requiring pull_request_target; the same rationale isn't yet applied here. Today pr-title.yml/do-not-merge.yml/pr-issue-linkage-self.yml still run in parallel and partially mask this, but the PR body says Phase 3.4 retires them, at which point this becomes the only enforcement and the bypass is complete.

2. (Medium) Load-bearing statuses: write always fails for fork-originated / Dependabot PRs

For pull_request runs from a fork (or Dependabot), GitHub forces GITHUB_TOKEN to read-only regardless of the job's declared permissions: (ci.yml#L698-L704). pr-contract's writes degrade gracefully (best_effort), but the commit-status write in ci-status/run.sh is explicitly not best-effort — after 3 retries it hard-fails the job, naming the missing permission (ci-status/run.sh#L172-L188). Every pull_request action type is full-mode, and carry-forward mode itself depends on a prior successful full-mode write, so a genuine fork PR can never produce a green ci-lanes status — independent of whether the lanes actually passed — and every later edited/labeled/unlabeled run then also fails since there's nothing to carry forward.

Neither of these is new — both were raised by the Claude and Codex reviews on the first commit (db7f749) — but since cd94cb2 doesn't address either, they still stand as blocking findings before this pattern becomes the sole required check (Phase 3.4).

No new issues found in the formatting-only delta (cd94cb2), and the injection-safety of PR content handling remains sound in both composites: title/body/labels are read via gh api + jq into files/variables and written back via --input <json-file>, never spliced into a shell command or an unguarded run: block (confirmed in pr-contract/action.yml#L75-L85 and ci-status/action.yml#L63-L72, both pass all PR-controlled values through env:).
· Branch

The repo's Biome lane covers `.github/scripts` against the fixture
config; the new assertions were hand-formatted. No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) Sep 4, 2026
@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) feat(actions): add pr-contract composite and carry-forward mode to ci-status Sep 4, 2026
@kyle-sexton kyle-sexton added do-not-merge Hard merge gate: do not merge while applied. and removed do-not-merge Hard merge gate: do not merge while applied. labels Sep 4, 2026
Review findings on #550 (amendments 3 and 4 to the Phase 3 design).

The contract-only decision is now a single expression, repeated verbatim
in `cancel-in-progress` and in every job `if:`:

  github.event.pull_request.head.repo.full_name == github.repository &&
  (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
   (github.event.action == 'edited' && !github.event.changes.base))

Two exclusions it adds are load-bearing. An `edited` event carrying
`changes.base` changed the base branch, so the merge commit the lanes
test changed with it and the run must be full. A fork pull request is
never contract-only: its token is read-only on `pull_request` whatever
`permissions:` requests, so it cannot record the lane state a later
carry-forward would read, and it runs the full workflow on every event
exactly as before.

`cancel-in-progress` keeps its `github.event_name == 'pull_request'`
guard ANDed with the negated predicate rather than replacing it. On a
push there is no `github.event.pull_request`, so the predicate is false
and `!(predicate)` alone would be true, re-arming the main-push burst
collapse #122 disarmed. The contract test now pins that AND explicitly.

In the ci-status composite, `event-action` and `carry-forward-actions`
are replaced by `contract-only` (defaulting to the predicate itself) and
`same-repo`. Carry-forward runs iff `contract-only` is true, branched on
before `same-repo` so a caller that overrides it owns the claim that the
lanes did not run. Full mode records the ci-lanes status only when
`same-repo` is true; a fork prints a `::notice::` and exits on the lanes
verdict alone, and the load-bearing failure on a refused write stays for
same-repository runs.

Five new harness cases: contract-only false aggregates and records (the
base-change shape); both boolean inputs reject an unrecognised value;
same-repo false skips the write and passes, and still fails on a failing
lane; contract-only true with same-repo false is still carry-forward.
The last is unreachable from the shipped defaults and is documented as
caller responsibility in the pr-contract README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 23s —— View job


Security review complete

  • Gather context (PR diff, changed files, CLAUDE.md rules)
  • Review .github/actions/pr-contract/ (action.yml, run.sh)
  • Review .github/actions/ci-status/ (action.yml, run.sh)
  • Review .github/workflows/ci.yml dogfooding changes
  • Cross-check prior review findings against current head SHA
  • Post findings

Re-ran a full security review against the current head, fcc8484. Since the last-reviewed head (cd94cb2), two commits landed: 945d464 (Biome formatting only, no logic change) and fcc8484 ("make the contract-only decision one predicate"), which meaningfully changes ci.yml and ci-status/run.sh.

Finding #2 from prior reviews is now fixed

The fork/statuses: write problem (fork PRs forced to a read-only GITHUB_TOKEN, hard-failing the load-bearing commit-status write after 3 retries) is resolved. ci-status now takes a same-repo input (default !github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository, action.yml#L34-L43) and run.sh skips the status write entirely for same-repo: false, reporting the lanes verdict and exiting instead (run.sh#L184-L190). The new contract-only predicate also excludes forks from ever being contract-only (ci.yml#L14, L45), so a fork PR always runs the full workflow and never depends on a carried verdict it could never have written. Both branches (same-repo: false passing and failing lanes, plus the unreachable-from-defaults contract-only: true + same-repo: false combination) are covered in run.test.sh. This also incidentally fixes the Codex P1 comment about edited firing on a base-branch change: the predicate now excludes edited events that carry github.event.changes.base (ci.yml#L20-L21), forcing those onto the full-run path.

Finding #1 is still open and unaddressed at this head

The pull_request vs. pull_request_target trust-boundary issue raised by both the earlier Claude and Codex reviews is untouched by this commit. ci.yml still triggers on plain pull_request (ci.yml#L6-L21), and the ci-status job still invokes the local composites directly from the checked-out PR head: uses: ./.github/actions/pr-contract and uses: ./.github/actions/ci-status (ci.yml#L722-L723).

For pull_request (unlike pull_request_target), GitHub runs the workflow file and every local action it references exactly as they exist on the PR's own head — for same-repository branches as well as forks, not just forks. The new same-repo check governs only whether the status write is attempted; it does nothing to change which copy of run.sh gets executed. A PR author (fork or same-repo branch) can still edit .github/actions/pr-contract/run.sh or .github/actions/ci-status/run.sh in their own PR to force an unconditional pass, and the now-sole required ci-status check goes green regardless of title, do-not-merge label, or issue-linkage state — no elevated token needed, since this doesn't require any write permission, only local script logic that runs with whatever token scope the event grants.

This is the same tampering vector pr-issue-linkage.yml's own header comment already documents and defends against by requiring pull_request_target. Today pr-title.yml / do-not-merge.yml / pr-issue-linkage-self.yml still run in parallel (unchanged, pull_request_target-only) and partially mask this gap; the PR body states Phase 3.4 retires them once this pattern is trusted, at which point this becomes the sole enforcement and the bypass is complete. Recommend resolving this (or explicitly gating Phase 3.4 on it) before this composite pair becomes the only required check.

Finding #3 (low confidence) is unchanged

pr-contract/run.sh was not touched between cd94cb2 and fcc8484 (only its README.md changed). The fenced/inline-code masking question relative to GitHub's real closing-keyword scan raised previously still stands as an open, unverified question — not re-analyzed further here since nothing changed.

No new issues found

The predicate refactor itself is internally consistent: ci-fanout-consolidation.test.cjs now pins each clause of the shared predicate (same-repo check, base-change exclusion) separately rather than just the assembled string, and confirms the gate reaches all 26 non-ci-status jobs and stays off ci-status itself. read_boolean in run.sh correctly rejects unrecognized contract-only/same-repo values rather than silently defaulting, matching the existing treat-skipped-as validation pattern. No new injection-safety issues in the changed scripts.
· Branch

@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) Sep 4, 2026
@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) feat(actions): add pr-contract composite and carry-forward mode to ci-status Sep 4, 2026
@kyle-sexton kyle-sexton added do-not-merge Hard merge gate: do not merge while applied. and removed do-not-merge Hard merge gate: do not merge while applied. labels Sep 4, 2026
Security review of #550: one blocking finding and four hardening items.

BLOCKING. The carry-forward read the combined status endpoint, which
collapses to one entry per context and exposes no author. Any
collaborator with write can POST a commit status, so a forged
`ci-lanes=success` followed by a label flip turned the sole required
check green over failing lanes — the label event gates every lane off,
and the carry-forward then trusted the forgery. It now reads the
per-context LIST endpoint, which carries `.creator` and is newest-first,
and accepts only the newest entry whose context matches AND whose
creator is `github-actions[bot]` with `type == "Bot"`. A later bot
failure therefore overrides an earlier bot success, and a later forged
success by a user is skipped rather than shadowing the bot's verdict.
Seven fixture cases cover it, including a user account whose login
merely impersonates the bot.

Also:

- A predicate drift guard. `ci-fanout-consolidation.test.cjs` now
  compares the `contract-only` default text in the ci-status action
  metadata against the negated expression in every non-`ci-status` job
  gate, normalised for the folded block scalar. Drift there is silently
  catastrophic: lanes gated off while `contract-only` resolves false
  makes the composite aggregate all-`skipped` results and record
  `ci-lanes=success` for a run in which nothing executed. Ablation-
  tested — narrowing the action default to one action turned the test
  red, and it went green again on restore. The composite deliberately
  does not refuse an all-skipped aggregation; a docs-only pull request
  reaches that legitimately.
- `find_marker_comment` selects only bot-authored comments carrying the
  marker and takes the newest, so a stranger's planted marker on a
  public repository cannot capture the advisory upsert.
- One `escape_annotation` helper applies GitHub's documented
  workflow-command escaping (`%`, CR, LF) to every attacker-controlled
  string quoted in an annotation: the title, the author login, and the
  linkage messages that quote body text.
- Both composites validate every value that reaches a `gh api` path
  before the first call — `sha`, `repository`, `pr-number`,
  `status-context` and both label inputs — and the label is
  percent-encoded in the label-removal path.

Harness counts: ci-status 22 to 29 cases, pr-contract 41 to 48.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 26s —— View job


Security review complete

  • Gather context (PR diff since last-reviewed head, CLAUDE.md rules)
  • Check skip-gate (open PR, security-relevant surface, prior review at fcc8484)
  • Review delta since last-reviewed head (fcc8484746e76e)
  • Cross-check standing findings (trust boundary, fork status write)
  • Post findings

The one new commit since the last-reviewed head, 746e76e ("harden ci-status carry-forward and pr-contract inputs"), is itself a security-hardening commit. It closes two real gaps proactively (not previously flagged by either automated review) and adds targeted test coverage for both:

1. Forged commit-status vector in ci-status carry-forward mode — now closed. Carry-forward mode previously read the combined-status endpoint (commits/{sha}/status), which exposes no author. Any collaborator with write access could POST repos/{repo}/statuses/{sha} a forged ci-lanes=success and then flip a label to turn the sole required check green over genuinely failing lanes. run.sh now reads the per-context status LIST and only honors the newest entry whose creator.login == github-actions[bot] and creator.type == Bot — both fields are server-assigned from the posting token's identity, not attacker-controllable in the POST body, and STATUS_CREATOR isn't wired through action.yml's env: block, so no caller can override the expected identity. Nine new harness cases cover this, including the impersonation case (login matches but type is User).

2. Workflow-command injection into annotations — now closed. pr-contract echoes attacker-controlled text (PR title, negated-closer quotes, author login) into ::error::/::warning::/::notice:: lines. The new escape_annotation helper applies GitHub's documented %/CR/LF escaping (in the correct order) before interpolation, so a title carrying a newline can no longer terminate the annotation and inject a second workflow command. Covered by a new CRLF-title test case.

3. Marker-comment hijack and API-path injection — now closed. find_marker_comment now requires .user.type == "Bot" before treating a comment as the upsert target — on a public repo, a stranger could otherwise plant the marker string first and capture every future upsert. repository, pr-number, and both label inputs (and, in ci-status, repository/sha/status-context) are validated against strict patterns before the first gh api call, closing a path-traversal-into-API-path class of bug (e.g., repository=owner/repo/../other). Label removal is now percent-encoded. All four are exercised by new negative-input test cases.

Standing finding — unchanged at this head

The trust-boundary issue raised by prior Claude and Codex reviews on this PR (db7f749, cd94cb2) is not addressed by this commit, since it doesn't touch ci.yml: ci.yml still triggers on plain pull_request, so GitHub executes the workflow file and every local composite it references (uses: ./.github/actions/pr-contract, uses: ./.github/actions/ci-status) exactly as they exist on the PR's own head, for same-repository branches as well as forks. A PR author can still edit either composite's run.sh (or ci.yml itself) within their own PR to force an unconditional pass — none of today's fixes change which copy of the script executes. This doesn't undermine today's fixes on their own terms (they close a real forgery/injection vector reachable by a different actor than the PR author, e.g. another collaborator with write access, or a race between two legitimate runs), but it means the sole-required-check design still depends on pr-title.yml/do-not-merge.yml/pr-issue-linkage-self.yml staying active as a backstop until this is resolved — worth keeping as an explicit blocker for Phase 3.4 as previously noted.

No new issues found

I was not able to execute run.test.sh directly in this sandbox (shell execution required approval that wasn't available), so this is based on static reading of the diff, not a live run — the PR body states both suites pass and the new test cases read as correctly targeting the fixed behavior.
· Branch

@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) Sep 4, 2026
@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) feat(actions): add pr-contract composite and carry-forward mode to ci-status Sep 4, 2026
@kyle-sexton kyle-sexton added do-not-merge Hard merge gate: do not merge while applied. and removed do-not-merge Hard merge gate: do not merge while applied. labels Sep 4, 2026
Code review of #550: one blocking finding and five cheap corrections.

BLOCKING. The previous commit's input validation rejected legal values.
GitHub labels may contain spaces and colons (`do not merge`,
`status: blocked`) and a commit-status context such as `CI Lanes` is
legal, so validating `do-not-merge-label`, `linkage-label` and
`status-context` against a no-whitespace pattern would have failed real
consumers. None of the three ever reaches an API path unencoded: the
blocking label is compared with `grep -qxF`, the linkage label travels
in a JSON body built by `jq --arg` and reaches only a percent-encoded
DELETE path, and the context goes through `jq --arg` into a comparison
or a payload. Those three checks are gone. The `sha`, `repository` and
`pr-number` checks stay, because those two do land in a path.

Also:

- `url_encode` emitted code points, so an emoji or accented label was
  mis-encoded and the label was never removed. It is now
  `jq -rn --arg text "$1" '$text|@uri'`, which percent-encodes the UTF-8
  bytes the API expects.
- The carry-forward selected `.[0]` of the matching entries, depending
  on the API's documented ordering. It now takes `max_by(.id)`, stating
  the intent directly, as `find_marker_comment` already did. Both new
  cases feed an oldest-first list.
- `expect_no_gh_call 'gh api'` could never fire: the shim logs `$*`,
  which begins `api -X GET`. Those seven assertions are now an
  empty-log check. Proven: with `require_pattern`'s `exit 1` removed,
  the two ci-status validation cases go red on it.
- The title regex required exactly one space after the colon; the
  conventional-commits parser behind semantic-pr.yml accepts more, so
  `feat:  two spaces` would have started failing. Now `: +`.
- The `token` input description and the README record that the token
  must be `GITHUB_TOKEN` or a GitHub App token: the comment upsert only
  matches a `Bot` author, so a classic PAT would post a new comment on
  every failing run. The two ci-status `require_pattern` messages now
  route through `escape_annotation` like every other annotation.

Harness counts: ci-status 29 to 31 cases, pr-contract 48 to 50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 5s —— View job


Security review complete

  • Gather context (PR diff since last-reviewed head, CLAUDE.md rules)
  • Check skip-gate (open PR, security-relevant surface, prior review at 746e76e)
  • Review delta since last-reviewed head (746e76e8e359c2)
  • Cross-check standing findings (trust boundary, fork status write)
  • Post findings

The one new commit since the last-reviewed head, 8e359c2 ("stop rejecting legal labels and contexts"), is a validation-correctness fix responding to code review, not new feature surface. It touches .github/actions/ci-status/run.sh, .github/actions/pr-contract/{run.sh,action.yml,README.md}, and both test harnesses — ci.yml is untouched.

No new issues found

I verified the three loosened validations against every sink each value reaches, and none regresses a previously-fixed issue:

The new url_encode (jq -rn --arg text "$1" '$text|@uri', run.sh#L67) correctly percent-encodes UTF-8 bytes rather than raw code points — the new needs-issue-linkage-é%C3%A9 test case confirms it. escape_annotation's ordering (%\r\n, ci-status/run.sh#L104-L110) is correct — escaping % first prevents double-escaping the CR/LF substitutions. max_by(.id) is a strict improvement over positional .[0] for the anti-forgery selection logic from the prior commit.

Low-severity, non-blocking observation: three ::error::/::warning:: annotations in ci-status/run.sh interpolate ${STATUS_CONTEXT} directly without routing it through escape_annotation (L148, L251, L258), unlike the require_pattern message at L121, which does. Similarly, pr-contract/run.sh#L194 interpolates ${DO_NOT_MERGE_LABEL} into an ::error:: line unescaped. Neither is currently exploitable — both values are workflow-author-configured composite inputs in this repo (ci.yml passes neither status-context nor do-not-merge-label, so both use their static defaults), not PR-title/body-controlled data, and the whitespace///# validation that was intentionally dropped was never the thing preventing CR/LF injection. Worth a follow-up only if this composite pair is meant to be a hardened template for other repos where these inputs could someday be wired to a dynamic expression.

Standing finding — unchanged at this head

The pull_request vs. pull_request_target trust-boundary issue raised by prior Claude and Codex reviews on this PR remains open: ci.yml is untouched by this commit, so it still triggers on plain pull_request and still invokes uses: ./.github/actions/pr-contract / uses: ./.github/actions/ci-status directly from the checked-out PR head, for same-repository branches as well as forks. A PR author can still edit either composite's run.sh (or ci.yml itself) within their own PR to force an unconditional pass. This doesn't require a security fix in this commit, but continues to mean the sole-required-check design still depends on pr-title.yml/do-not-merge.yml/pr-issue-linkage-self.yml staying active as a backstop until resolved — flagged as a blocker for Phase 3.4 in every prior review on this PR.

I was not able to execute run.test.sh directly in this sandbox (shell execution required approval that wasn't available), so this is based on static reading of the diff — the new/changed test cases (space-carrying label, : + regex, non-ASCII percent-encoding, empty-log assertion) read as correctly targeting the fixed behavior.
· Branch

@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) Sep 4, 2026
@kyle-sexton kyle-sexton changed the title feat(actions): add pr-contract composite and carry-forward mode to ci-status (wip) feat(actions): add pr-contract composite and carry-forward mode to ci-status Sep 4, 2026
@kyle-sexton kyle-sexton added do-not-merge Hard merge gate: do not merge while applied. and removed do-not-merge Hard merge gate: do not merge while applied. labels Sep 4, 2026
@kyle-sexton
kyle-sexton merged commit 449157a into main Sep 4, 2026
148 of 150 checks passed
@kyle-sexton
kyle-sexton deleted the ci-perf/phase-3-pr-contract branch September 4, 2026 19:36
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…eltas (#570)

No related issue: fleet fan-out of
melodic-software/claude-code-plugins#3688 (closed); this repo's part of
that remediation.

## Summary

The fleet plugin list (standards
`components/cloud-environment/fleet-plugins.json`, standards#542) is
what every cloud snapshot installs, and the canonical bootstrap reads it
overlaid with each repo's `.claude/settings.json`. A committed block
that mirrors the whole catalog is therefore redundant in the cloud and,
locally, writes one project-scope install record per entry into the
user's `installed_plugins.json` on every session start in every checkout
(claude-code-plugins#3813). This repo declares no per-repo deltas, so
its block is reduced to `{}`.

## Fix

- `.claude/settings.json`: `enabledPlugins` reduced to `{}`.
`extraKnownMarketplaces` and everything else in the file is unchanged.

A repo that later needs a plugin disabled adds a single
`"<plugin>@melodic-software": false` entry; a repo that needs one
outside the fleet list adds a `true` entry. Template and rationale:
claude-code-plugins `docs/CLOUD-FLEET-SETUP.md`.

## Verification

- `jq -e . .claude/settings.json` parses; `git diff` touches only the
`enabledPlugins` block.
- The cloud install path is unaffected: the snapshot installs the fleet
list at build time (verified on stamp `2026-09-06.2` for
claude-code-plugins and medley), and the canonical bootstrap's drift
repair reads the same list from `/opt/melodic-fleet-plugins.json`.

## Related

- melodic-software/claude-code-plugins#3688, #3917, #3813
- melodic-software/standards#542, #550

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016e6sBmnTGcidwvSrM86NYp

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant