Skip to content

ci: add the Bits review merge gate - #992

Open
jbachorik wants to merge 11 commits into
mainfrom
friendly-lunar-newt
Open

jbachorik wants to merge 11 commits into
mainfrom
friendly-lunar-newt

Conversation

@jbachorik

@jbachorik jbachorik commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?:
Adds the Bits review merge gate: a merge-blocking CI status (bits-review/head) published per distinct resolved open-main head and enforced by a Chainguard trust policy. Only the trusted Bits publisher app's PASS on the exact head SHA counts; results are conjoined across PRs sharing that head. The gate fails closed: edited or unknown-bot reviews block, and there is no token fallback. The event receiver fans the publisher out per distinct resolved head. The publish decision is revalidated: two consecutive snapshots must fingerprint-match before anything is written, and three mismatches suppress the write entirely.

Motivation:
A Bits review that has gone stale (head moved) or was edited after the fact must never authorize a merge. The gate makes the merge decision derive only from the trusted publisher's current-head result, so stale-green and tampered reviews cannot pass a PR.

Additional Notes:

  • Ships inert: activation (Chainguard enforcement flip, Octo-STS app creation in the Datadog org) is deliberately out of scope and needs separate authorization; the workflows and policy are in place but do not block until then. Design: doc/BitsReviewMergeGateDesign.md.
  • Human review required for the trust boundary (fail-closed policy, trusted-publisher pinning, allowlist) — the operator approved the policy decisions on record in PROF-16661.
  • Reviews: pre-PR Sphinx review — 0 Critical, 0 High (10 LOW/INFO left unfixed, none mandatory). PR-comment fixes (snapshot revalidation restored, response-body read moved into the retry handler, non-object GraphQL roots rejected before field access) were re-reviewed by Sphinx with every acceptance item verified. The two LOW follow-ups (body-read classification boundary values; rate-limit protocol on body-read failures) are fixed too. Open findings: none. The rate_limited_last stale-flag LOW is fixed (the flag now names the last attempt's classification, pinned both directions). The empty-P(H)-after-mismatch LOW is rejected: the empty path never writes a wrong status (at worst an older one stays visible for one trigger cycle, then the next sweep overwrites it); the transient-listing premise is unobserved for this endpoint and must land in the sub-second window between S(i) and S(i+1); the dominant real cause is the PR having genuinely closed/merged/moved heads — where exit 0 is correct and treating it as unstable would red-job routine merge timing for no safety gain; and the job exit code is not the blocking signal — the status is.
  • This PR touches CI that handles a status-publishing token (Octo-STS trusted publisher, statuses-only policy). PSEC review recommended — left unchecked below for the author's call.

How to test the change?:

  • bash .github/scripts/tests/test_bits_review_gate.sh — hermetic suite: "Ran 296 tests in 9.208s OK" on head c71c481 (runner, policy, API, publish, workflow-contract families).
  • Contract tests pin the workflows: trusted main checkout with persist-credentials: false, receiver timeout-minutes: 5, no secrets or token references.

For Datadog employees:

  • If this PR touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a security review (run the dd:platform-security-review skill, or file a request via the PSEC review form). bewaire also runs automatically on every PR.
  • This PR doesn't touch any of that.
  • JIRA: PROF-16661

Gate: only the trusted Bits publisher app's PASS on the exact open-main
head counts, shared as a conjunction across PRs sharing a head. Fail
closed on edited or unknown-bot reviews; never fall back to other
tokens. Trusted publisher and allowlist live in the Chainguard policy;
the event receiver fans the publisher per distinct resolved head.
@jbachorik jbachorik added the AI label Nov 13, 2026
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Pipelines

✨ Unblock PR with BitsAI

❌ Errors

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 5 Pipeline jobs failed

DDCI - DataDog/java-profiler | integration-test-x64-musl: [hotspot, 25] — 🔧 Needs a code fix, caused by this PR

View more details · View in DDCI

DDCI - DataDog/java-profiler | integration-test-x64-musl: [openj9, 25] — 🔧 Needs a code fix, caused by this PR

View more details · View in DDCI

DataDog/java-profiler | integration-test-x64-musl: [hotspot, 25] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

View all 5 failed jobs.

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: ddeb78e | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Scan-Build Report

User:runner@runnervmccnms
Working Directory:/home/runner/work/java-profiler/java-profiler/ddprof-lib/src/test/make
Command Line:make -j4 all
Clang Version:Ubuntu clang version 21.1.8 (6ubuntu1)
Date:Fri Nov 13 18:17:28 2026

Bug Summary

Bug TypeQuantityDisplay?
All Bugs2
Logic error
Dereference of null pointer1
Unused code
Dead initialization1

Filter Results by File

Reports

Bug Group Bug Type ▾ File Function/Method Line Path Length
Unused codeDead initializationreferenceChains.cppapplyOomRamp4971
Logic errorDereference of null pointerperfEvents_linux.cppunregisterThread72026

@jbachorik

Copy link
Copy Markdown
Collaborator Author

@DataDog review

@dd-octo-sts

dd-octo-sts Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #43480868685 | Commit: 316206f | Duration: 19m 30s (longest job)

✅ All 32 test jobs passed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ✅ ✅ - -
8-librca - - ✅ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ✅ ✅ - -
11-librca - - ✅ ✅
17 ✅ ✅ - -
17-graal ✅ ✅ - -
17-j9 ✅ ✅ - -
17-librca - - ✅ ✅
21 ✅ ✅ - -
21-graal ✅ ✅ - -
21-librca - - ✅ ✅
25 ✅ ✅ - -
25-graal ✅ ✅ - -
25-librca - - ✅ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Summary: Total: 32 | Passed: 32 | Failed: 0


Updated: 2026-11-13 18:38:57 UTC

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: FAIL

The publisher can emit success without revalidating its snapshot, and response-body failures can prevent a blocking status update. Both defects should be corrected before enforcement activation.

Open Bits AI session

🤖 Bits Code Review · Commit a3c8e5f

Comment thread .github/scripts/bits_review_gate.py Resolved
Comment thread .github/scripts/bits_review_gate.py Resolved
revalidated_decision again reads S(i), computes D(i), re-reads and
fingerprint-compares S(i+1); a mismatch discards D(i) and restarts
from the new snapshot, three mismatches return unstable and the
publish CLI exits 1 without writing. Drops an unused import from the
publish tests.
…dler

A timeout or reset during response.read() now falls into the existing
retry and ApiError conversion instead of escaping the fetch loop; the
best-effort summary-write swallow gets the comment the linter wants.
A non-retryable non-2xx whose body read fails raises ApiError after
one attempt instead of retrying like a transport failure; a 2xx body
read keeps retrying. Pins the revalidation flicker case (S1 pass, S2
edited, S3 pass again) returning the last revalidated decision.
@dd-octo-sts

dd-octo-sts Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

❌ 38 passed, 2 failed out of 40 expected configurations

Test Matrix

Platform JDK 8 JDK 11 JDK 17 JDK 21 JDK 25
glibc-x64-hotspot ✅ ✅ ✅ ✅ ✅
glibc-x64-openj9 ✅ ✅ ✅ ✅ ✅
glibc-arm64-hotspot ✅ ✅ ✅ ✅ ✅
glibc-arm64-openj9 ✅ ✅ ✅ ✅ ✅
musl-x64-hotspot ✅ ✅ ✅ ✅ ❌
musl-x64-openj9 ✅ ✅ ✅ ✅ ❌
musl-arm64-hotspot ✅ ✅ ✅ ✅ ✅
musl-arm64-openj9 ✅ ✅ ✅ ✅ ✅

Failure Details

musl-x64-hotspot-jdk25

Tracer+profiler:

"  This may indicate tracer is not capturing any requests"
""
"[7/8] Checking for unexpected events..."
"  ✓ No unexpected events found"
""
"[8/8] Scenario-specific validation (ddprof_with_tracer)..."
"  Validating tracer+profiler scenario..."
"  ✓ Tracer+profiler scenario checks passed"
""
"=== Validation Summary ==="
"ExecutionSample:            0.0 events (datadog.ExecutionSample)"
"Stack traces:               0.0 samples"
"Thread diversity:           0.0 threads"
"Allocation samples:         0 events (jdk.ObjectAllocationSample)"
"ThreadAllocationStatistics: 46 events"
""
"VALIDATION_FAILED: One or more checks did not pass"

Script executed successfully: 425 commands
VALIDATION_FAILED: Validation checks did not pass
musl-x64-openj9-jdk25

Profiler-only:

"  Skipped (tracer not expected in this configuration)"
""
"[7/8] Checking for unexpected events..."
"  ✓ No unexpected events found"
""
"[8/8] Scenario-specific validation (ddprof_only)..."
"  Validating profiler-only scenario..."
"  ✓ Profiler-only scenario checks passed"
""
"=== Validation Summary ==="
"ExecutionSample:            0.0 events (datadog.ExecutionSample)"
"Stack traces:               0.0 samples"
"Thread diversity:           0.0 threads"
"Allocation samples:         0 events (jdk.ObjectAllocationSample)"
"ThreadAllocationStatistics: 42 events"
""
"VALIDATION_FAILED: One or more checks did not pass"

Script executed successfully: 425 commands
VALIDATION_FAILED: Validation checks did not pass

Links

@jbachorik
jbachorik marked this pull request as ready for review November 13, 2026 16:03
@jbachorik
jbachorik requested a review from a team as a code owner November 13, 2026 16:03
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T16:05:45.639382Z 2808034 Draft marked ready
ℹ️ 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.

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: FAIL

A GraphQL response containing null or an array crashes the publisher before it writes a blocking error status, leaving any previous success visible. This undermines the fail-closed guarantee once enforcement is activated.

Open Bits AI session

🤖 Bits Code Review · Commit 2808034

Comment thread .github/scripts/bits_review_gate.py Outdated
Comment on lines +293 to +294
if not isinstance(document, dict) or document.get("errors"):
raise ApiError("GraphQL errors for PR %s: %r" % (number, document.get("errors")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Reject non-object GraphQL responses before accessing fields

If the reviews endpoint returns JSON null or [], constructing this error calls document.get(...) and raises AttributeError instead of ApiError. The publisher then terminates without writing the intended blocking api-error status, leaving any earlier success visible. Once enforcement is activated, this violates the gate’s fail-closed behavior. Reject the non-object root before accessing its fields.

Suggested change
if not isinstance(document, dict) or document.get("errors"):
raise ApiError("GraphQL errors for PR %s: %r" % (number, document.get("errors")))
if not isinstance(document, dict):
raise ApiError("malformed GraphQL response for PR %s" % (number,))
if document.get("errors"):
raise ApiError("GraphQL errors for PR %s: %r" % (number, document.get("errors")))

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f1d5a11 — the non-dict root now raises ApiError before any field access, so a JSON null/[] reviews response fails closed into the blocking error status instead of crashing the publisher with AttributeError. Pinned at both levels: the ApiError at the fetch seam and the published error status end-to-end; reverting the guard is verified to fail both tests.

@jbachorik
jbachorik requested a review from rkennke November 13, 2026 16:35
A JSON null or [] root raised AttributeError inside the ApiError
message expression instead of ApiError, terminating the publisher
before the blocking status was written. The non-dict root now raises
ApiError and flows into the error decision.

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: PASS

More details

The PR adds a trusted, current-head Bits review gate with snapshot revalidation and deferred activation. The GraphQL crash is fixed: non-object responses now produce a blocking error status; no reportable findings remain, while production activation and enforcement remain unvalidated.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit f1d5a11

A rate-limited response whose body read fails now sets the
rate-limit window and raises RateLimitError at exhaustion instead of
retrying as a plain transport failure; the body-read classification
boundaries (status 500 and 300) are pinned by tests.
rate_limited_last resets every attempt, so a mixed sequence that
started rate-limited but ended in plain server errors reports a
transport ApiError, not RateLimitError.
@rkennke

rkennke commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Agentic review (Sphinx, standard effort)

  • Reviewed range: dbaeab619..2808034 (merge base dbaeab6199e33cb21248913c2e84756a1dcc26e1, head 2808034380bf628dc5f4134bcadd285bc187f2de).
  • Current head: 1c41a5685, two commits later. Those two commits were not reviewed. They do not touch the code behind any finding below. Line numbers refer to 2808034.
  • Result: no Critical or High findings. Each finding below was checked against the code.

Medium

  1. No timeout-minutes on the privileged jobs (.github/workflows/bits-review-gate.yml:50)
    resolve and publish inherit the 360-minute default. The receiver workflow sets 5. _fetch can sleep up to 300 s per retry, and a publish reads the snapshot at least twice. publish holds the per-SHA group bits-review-gate-<sha> with cancel-in-progress: false, so a stalled run also delays later updates for that SHA. The comment on the 300 s cap says it exists so a rate limit "cannot stall the workflow run". Without a job timeout, the cap bounds each sleep but not the run.
    Suggestion: add job timeouts (for example 10 and 15 minutes) and pin them in PublisherWorkflowTest, as the receiver test does.

  2. An unstable snapshot leaves an earlier success in place (.github/scripts/bits_review_gate.py:668)
    After three fingerprint mismatches, _publish_status exits 1 and writes nothing. Every other failure path publishes error, so it fails closed. On this path the last status stays, and it can be success. The gate stays open until the next event or the 30-minute sweep. The design doc accepts this explicitly. A non-success write (pending, or error with reason unstable) would close the window at little cost. Was that option rejected on purpose?

  3. The design doc and the code disagree on how a completed receiver run resolves heads (bits_review_gate.py:817-825 vs doc/BitsReviewMergeGateDesign.md:253-254)
    The doc says: "If none match, scan all open-main heads." The code does the full scan only when the run has no PR-number list. When the PR numbers match no open PR, it publishes the run head alone and leaves the rest to the sweep. One of the two should change.

Low

  • _fetch mislabels the failure (bits_review_gate.py:331): rate_limited_last is never reset. A final 5xx, network or 4xx failure after one earlier rate-limited attempt raises RateLimitError, and :rate-limit appears in the status description. The decision is still error, so only the diagnosis is wrong.

  • read_statuses omits per_page (bits_review_gate.py:246): it gets GitHub's default page size of 30, while list_open_main_prs asks for 100. With MAX_FETCH_PAGES=20, the read fails at about 600 statuses. A failed read always triggers a POST, so the write is no longer skipped when the status is unchanged.

  • Token headers can follow redirects (bits_review_gate.py:114): urlopen uses the default redirect handler. That handler copies Authorization to the redirect target, including another host, and on the POST that header carries the status token. Only the pagination Link URLs are origin-checked. GitHub does not redirect the API across hosts today, so this is hardening only. A no-redirect handler that raises ApiError would close it.

  • Wrong AC label (test_bits_review_gate_workflows.py:538): RunnerDiscoveryTest calls itself AC-12. In the design doc, AC12 is the Octo-STS claim-denial test drive, so the label suggests AC12 is covered locally when it is not.

  • Untested branches (mutation analysis, bits_review_gate.py): no test fails if any of these change:

    • the state or target_url checks in _matches_decision (:630, :632);
    • the context filter in _selected_app_status (:611);
    • the base-change guard when only one of GITHUB_CHANGES_BASE_REF / GITHUB_CHANGES_BASE_SHA is set (:801);
    • the 500 and 300 status boundaries in _fetch (:347, :355, :363).

    The first three guard which status is published or skipped.

🤖 Generated with Claude Code

- restore the contract's unmatched-selector fallback: a workflow_run
  matching no open-main PR resolves the run head plus every open-main
  head, not the run head alone; pin the one-variable base-change guard
- read_statuses asks for per_page=100 so the 20-page cap fails at
  about 2000 statuses instead of 600
- the default transport refuses redirects, so the Authorization header
  (the status token on the POST) can never follow one to another host
- resolve gets timeout-minutes 10 and publish 15: without a timeout
  they inherit the 360-minute default while _fetch can sleep 300 s per
  retry, and a stalled publish holds its per-SHA concurrency group
- pin the unpinned suppression-comparison branches (state, target_url,
  foreign context) and drop RunnerDiscoveryTest's wrong AC-12 label
The two DefaultTransportTest tests only inspect the module objects, so
reverting _urllib_transport to plain urlopen left them green while
reintroducing redirect-token forwarding. The new test drives the
transport through a recording opener swapped into _NO_REDIRECT_OPENER
and fails on that revert (verified by mutation).
@rkennke

rkennke commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Security follow-ups on the publisher and the allowlist

1. Make the app binding of bits-review/head a hard activation precondition

The Octo-STS policy holds up. Octo-STS anchors every pattern, and the job_workflow_ref and ref_protected claims mean only bits-review-gate.yml on protected main can mint the token. That protects the token, but not the status: GitHub lets any token with statuses: write write any status name.

Without a source binding, a same-repo PR can add a workflow with permissions: statuses: write and post bits-review/head: success to its own head SHA. The gate would pass without any Bits review.

The design already plans the binding: expected publisher app 1157446 (dd-octo-sts, verified with gh api /apps/dd-octo-sts), with AC11 reading it back. Please also:

  • State this threat in the design doc, next to "App-source binding does not isolate the gate from internal services…". Readers then see why the binding is required and not optional.
  • Make it explicit that the required status is added only with the app binding, in the same change. A required status without the binding is worse than no gate.
  • Add a case to AC9 or AC10: a same-repo branch workflow posting bits-review/head: success with its own GITHUB_TOKEN does not satisfy the required check.

2. Source of the eight-ID allowlist

All eight IDs resolve to Datadog-owned Bot accounts. On this repo, only datadog-official[bot] (214633350) and datadog-datadog-prod-us1[bot] (88084959) have posted Bits reviews that I could find. Every allowlisted ID can produce a PASS, so each one widens the trust boundary.

  • Where does the list come from? Please link the source that confirms the other six (datadog-datadog-prod-us1-2, datadog-datadog-us1-prod, datadog-prod-us1-3 to -6) are Bits review shards. Also say who owns the list and keeps it current.
  • The rationale given is that "a two-account allowlist can ignore a newer shard's FAIL". In the code, an unlisted bot's marked review yields untrusted-origin, which blocks the PR. So a shorter list blocks a PR; it cannot let a FAIL through. Is the reason something else, for example avoiding false blocks when Bits moves to another shard?
  • Can any of these apps post a review whose body includes user-controlled text, such as quoted PR content? If so, marker validation is the only remaining defense against an injected verdict=PASS. Please confirm this cannot happen, or name the check that prevents it.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants