diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95a71709e..25f98d596 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -933,62 +933,45 @@ jobs: ${{ runner.temp }}/production-rolling-evidence if-no-files-found: error - conventional-commits: - name: Validate conventional commit format + ipr-policy: + name: IPR Policy / Signature runs-on: ubuntu-latest timeout-minutes: 10 - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + permissions: + contents: read + pull-requests: read # Resolve the actual merged contributions and numeric PR author IDs. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + ref: ${{ github.sha }} fetch-depth: 0 - - - name: Validate PR commits - uses: amannn/action-semantic-pull-request@v6 + persist-credentials: false + # The existing PR/comment workflow records agreements. This job only + # reads the canonical ledger and verifies the actual included PR authors. + # Actions itself supplies the required App 15368 check-run provenance. + - name: Verify contribution agreements for the exact source + run: python3 -m scripts.check_main_policies ipr env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} - - name: Validate individual commits - run: | - # Get the base branch - BASE_SHA=$(git merge-base origin/${{ github.base_ref }} HEAD) - - # Check each commit since the base - echo "Validating commits since $BASE_SHA..." - git log --format="%H %s" $BASE_SHA..HEAD | while read sha message; do - # Skip merge commits. Three GitHub/git-created shapes: - # - "Merge into " — the merge-queue API path - # (clicking "Update branch" on a PR) - # - "Merge branch '' [into ]" — `gh pr update-branch` - # and `git merge ` defaults - # - "Merge remote-tracking branch '' [into ]" — - # `git merge origin/` default - if echo "$message" | grep -qE "^Merge ([0-9a-f]+ into [0-9a-f]+|(remote-tracking )?branch '[^']+')"; then - echo "⊙ Skipping merge commit: $sha" - continue - fi + conventional-commits: + name: Validate conventional commit format + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + permissions: + contents: read - # Check if message matches conventional commit format - if ! echo "$message" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+'; then - echo "❌ Commit $sha does not follow Conventional Commits format:" - echo " $message" - echo "" - echo "Expected format: [optional scope]: " - echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" - echo "" - echo "Examples:" - echo " feat: add new feature" - echo " fix: resolve bug in parser" - echo " feat(api): add new endpoint" - echo " feat!: breaking change" - exit 1 - else - echo "✓ $sha: $message" - fi - done - echo "" - echo "✅ All commits follow Conventional Commits format" + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate PR source or every integrated main commit and breaking footer + run: python3 -m scripts.check_main_policies conventional downstream-imports: name: Downstream import smoke (representative consumer symbols) diff --git a/CLAUDE.md b/CLAUDE.md index a1f63d77a..c12ef2104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,8 @@ context-echo path. **GitHub Actions Secrets** - Secret names matter! Check actual secret name in repository settings -- Common pattern: `PYPY_API_TOKEN` (not `PYPI_API_TOKEN`) for PyPI publishing +- Guarded PyPI publishing uses environment-bound Trusted Publishing and attestations, not a static API token +- Legacy PyPI/App credentials are retired only through the separately authorized historical-run audit in `docs/releasing.md` - Test locally with `python -m build` before relying on CI **Release Please Workflow** diff --git a/docs/releasing.md b/docs/releasing.md index 8bd946e79..9507f4465 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -37,16 +37,67 @@ both the dispatch SHA and workflow-definition SHA. Only a dispatch of `refs/heads/main` in this repository, on attempt 1, can proceed. Current main is read again at the end of each gate. A protection or API read error stops the run. -All effective required status checks need an explicit App binding, exact SHA, +Required checks from **both** the effective ruleset inventory and the separate +`GET /branches/main` protection summary need an explicit App binding, exact SHA, `completed/success`, and completion within 24 hours. The protected runtime CI floor cannot disappear through configuration changes. The selected `ci.yml` main-push run and every job in its selected, still-current attempt must also succeed; -required runtime check IDs must belong to that run. Missing, skipped, neutral, +required runtime and policy check IDs must belong to that run. Missing, skipped, neutral, cancelled, failed, ambiguous, or incomplete evidence is never success. The invocation and installed acceptance expire after 24 hours, including approval waits. A fresh successful CI attempt can be selected explicitly; a later attempt invalidates the previously selected one. +The native `IPR Policy / Signature` CI job follows the +[central signature policy](https://github.com/adcontextprotocol/adcp/blob/82a671607c92945f0fec513c4375af583fdea914/signatures/README.md) +and reads the ledger at one +resolved immutable `adcontextprotocol/adcp` main SHA. For every first-parent +integration after the last published source +`3e76aa54623529a3dda01cd690b8a5c287c75641` (beta.15), it verifies the unique +merged PR, exact merge SHA, base repository/main, and the author's numeric GitHub +ID against the canonical agreement records. That historical floor never advances +automatically. The canonical policy requires the PR author's agreement and +exempts authenticated bot accounts. Unknown authors, signatures recorded after +merge, direct pushes without an associated merged PR, and unreadable or ambiguous +records fail. No PR status or context name is used as a signature. The job has +only read permissions; GitHub Actions supplies the authenticated App 15368 +check-run identity. The existing PR/comment workflow still records signatures; +after signing, rerun ordinary CI if its read-only check previously failed. + +`Validate conventional commit format` checks actual commit subjects **and +bodies** for the main integrations after published beta.15. Historical IPR +checks retain that same floor. There is no moving date or commit-range cutoff +for grandfathering commit messages. + +Normal GitHub two-parent merges use a `Merge pull request ...` subject. For +those commits, the check validates the conventional message stored in the merge +body, including its breaking footer; it never substitutes the current PR title. +A single-parent commit cannot use that wrapper to bypass validation. PR runs +also check the PR title/body and individual non-merge commits. Future breaking +`!` commits require a `BREAKING CHANGE:` footer in the actual commit. + +Two already-merged commits have historical footer exceptions, each bound to +both the exact commit SHA and its committed conventional subject: + +- #1174 squash `6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac`: + `fix(reporting)!: scope configuration generations by account (#1174)`. +- #1192 merge `34c8f6d929aeac3407e2f595104a8e903e572623`: + `feat(reporting)!: enable production tier status and ownership` in its merge body. + +Their breaking titles and reviewed PR migration notes carry the release signal; +their historical missing footers are not new release blockers. Another commit +with either title receives no exception. Preserve their history and the +[account-qualified generation migration notes](reporting-release-notes.md#account-qualified-configuration-identity) +and [production migration sequence](reporting-production.md#migration-drain-and-activation). + +The current pinned [Release Please 17.6.0 prerelease strategy](https://github.com/googleapis/release-please/blob/v17.6.0/src/versioning-strategies/prerelease.ts) increments an +existing prerelease's suffix. From `8.0.0-beta.15`, the staged recommendation is +**`8.0.0-beta.16` / PEP 440 `8.0.0b16`**, including the breaking foundation change. +`prerelease-type: rc` does not rename an existing beta suffix. An intentional +channel change needs a reviewed Release Please version instruction and its +normal proposal, not a standalone `pyproject.toml` edit. Review the proposed +manifest, project version and changelog together before the release merge. + Acceptance builds exactly one wheel and one sdist. It verifies the normalized project/Release Please version, distribution metadata, SHA-256 hashes, every archive member, complete SDK payload and tracked schema inventory, and sdist @@ -59,6 +110,63 @@ automatically; their dependencies and full #1172 contract need review on the integrated head. This suite is not an assertion that the unmerged stack's separate cross-language or independent-review acceptance has completed. +For the staged release, the old reporting directory alone is insufficient. +Acceptance must run the frozen real-process/PostgreSQL four-language-quadrant +foundation corpus, including stable/skew repetitions back-to-back, against +each installed wheel and sdist. Use the harness owner's actual applicability +contract; do not label raw transport as semantic reconciliation or invent +managed-delivery coverage for Core-only peers. The final integration must bind +that corpus, package pins and complete results into the existing acceptance +manifest and recheck them in both writers and recovery. Until that frozen +implementation is wired and independently accepted, publication is blocked. + +**#1199 freeze blocker, confirmed by its owner on 2026-09-22:** no harness +commit or acceptance result contract is frozen. The development runner currently +marks every cell incomplete and always emits `acceptance: false`; it is not wired +into these release workflows. Accepted Python `1f953c40d761be71d11fde84c78359ff2074fe7c` +still has typed explicit-scope and exact-revision request blockers. The controlling +TypeScript rc.42 pin lacks the public Core buyer API and has managed +official-precedence failures; supplemental rc.44 is not a substitute. Several +required semantic, retry/activity, skew and CLI/storyboard lanes and immutable CI +pinning remain unfinished. Wait for the owning fixes, accepted immutable inputs, +frozen complete harness and final integrated review. Do not construct a success +adapter around development results or treat the old installed tests as that proof. + +The frozen contract must resolve the blocking candidate and supported previous +TypeScript pins to exact versions, tarball integrity and npm-generated locks, and +record the actual Node 22.12.0 SDK floor executable. Existing floating `latest` +and `[adcp-3.0, latest]` CI results are not candidate interoperability acceptance. +Keep latest TypeScript/Python canaries visible and nonblocking, outside the +selected blocking main CI run whose jobs must all succeed. Preserve legacy +compatibility, resolving and recording the exact identity of any blocking floor, +and require the declared unsupported-version errors. + +Resolve the complete five-storyboard inventory from **each exact installed pin**: +`reliable_reporting_managed_delivery`, `reliable_reporting_reconciled_billing`, +`reporting_consumer_status`, `reporting_core`, and `reporting_core_declaration`. +Persist each storyboard's step/stateful counts and assert complete execution +against that resolved inventory. The experts measured 64 steps/61 stateful at +rc.38, but **89 steps/84 stateful at rc.42 and rc.44**. The rc.38 count is historical +evidence, not an acceptance target or permission to run a subset. Provenance +verification and signing/DDL tests are separate evidence from the complete matrix. +The experts verified rc.42/rc.44 npm signatures and source-to-tarball attestations; +record their actual Node 24.19.0 verification tooling separately from the Node +22.12.0 execution floor. TypeScript's public seller composition exists; its +configuration work and incomplete matrix execution must not be described as +missing SDK primitives. Require the canonical validator to be a function, without +an optional/truthy fallback. + +Candidate Python wheels and sdists must be installed by **local exact path and +SHA-256**, bound to accepted source SHA/tree and locked dependencies/runtime. +The accepted development source still declares occupied `8.0.0b15`; never obtain +the candidate by resolving `adcp==8.0.0b15` from PyPI. Published beta.15, each expert's +independent build of `1f953`, corrected source, and the final integrated/versioned +main build are distinct inputs with their own provenance and fresh acceptance. +Even identical versions and source inventories cannot substitute different bytes; +the writer and recovery regressions explicitly reject that substitution. The +final frozen harness must enforce the same negative, dependency identity and +artifact binding in its own result contract. + The immutable `release-acceptance-RUN_ID-1` artifact contains the two distributions and `acceptance.json`: source SHA/tree, invocation/CI attempt, version, file hashes, full archive inventories, installed identities, test inventory/report hashes, @@ -145,20 +253,118 @@ this operator trust boundary is unacceptable, a separately reviewed policy verifier is a design prerequisite; do not weaken the gate or grant an administrative writer token to the build job. -**Observed configuration blockers on 2026-09-20:** effective main rules require -`IPR Policy / Signature` and `Validate conventional commit format`. On main -`f6e9c15333db8e657ba96a79d806194dfc0e0447`, the first is absent and the second -is skipped; they currently run as PR policy. No release environment or main -freeze existed. The guard intentionally cannot authorize that state. Provide -reviewed, real exact-main successful checks without weakening protection or -substituting PR/parent checks. The classic branch-protection endpoint returned -403; effective rules were readable through `/rules/branches/main`. An inaccessible -administrative endpoint is a read limitation, not an exemption or approval. -The rules also retain PR reviews and CodeQL merge protection. GitHub evaluates -code-scanning merge protection separately from status checks; the guard does -not equate an `Analyze` job with that policy's result. Ordinary protected merges -and independent exact-head review remain required. Administrative rule-suite -history also returned 403 with this workspace's integration credential. +**Observed external blockers on 2026-09-22:** the only visible environment is +`github-pages`; the release environments and main freeze are not configured. +The coordinator's authorized `PUT .../environments/release-proposal` returned +HTTP 403 `Resource not accessible by integration`, with no change. A reported +repository `permissions.admin: true` does not grant an integration token the +endpoint's Administration/write permission. Do not retry with that same +integration or ask for credentials in chat. Use a separately authorized operator +with the actual endpoint capability. Variables metadata also returned 403: +`RELEASE_PUBLICATION_ENABLED`, the freeze attestation, and App variables are +**unknown**, not proven absent. Dedicated App/PyPI trust must be verified by their +authorized administrators. The new workflows are already `active`, but were not +dispatched; workflow 204238826 remains `disabled_manually`. + +The native main policy producers in this follow-up must actually succeed on the +final integrated main; local tests or old PR statuses do not supply those check +runs. The full classic branch-protection settings endpoint returned 403, but +`GET /branches/main` exposes a separate readable summary. On 2026-09-22 it listed +17 contexts versus 14 in `/rules/branches/main`, adding literal `check / check` +from App **15368**, `CodeQL` from App **57789**, and `GitGuardian Security Checks` +from App **46505**. The guard enforces both inventories without name aliases or +App substitution; missing context-to-App bindings or unreadable inventories fail. +The rules also retain PR reviews and separate CodeQL scanning merge protection. +An inaccessible administrative endpoint is a read limitation, not an exemption. + +The #1174 head `6c74a458b8ee2a34c47c154186b950faed50f4fa` has no literal +`check / check` or `CodeQL` evidence in the complete check/status inventory. +The IPR workflow formerly called reusable job `check` from caller job `check`; +hardening commit `688b3e4c6f4551f7f04d4ce68f6a69a5ee943523` replaced that call with +a pinned local job named `check`. This matches GitHub's documented +[reusable-job naming](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/troubleshooting-rules), +and explains a possible obsolete protected name; it does not authorize changing +the rule or generating a success alias. An authorized administrator must verify +the intended IPR binding and review any migration to the real producer. + +The visible `Analyze` jobs belong to **Code Quality**, run `35042345063`, path +`dynamic/github-code-quality/codeql`, with `analysis-kinds: code-quality` and +`python.quality.sarif`. The coordinator retained the original log with SHA-256 +`5bebe1a8c9e917b77ceec59d97e8f728adcd0cca623cdeaa53989a49b78c08b4`. +These Actions/App 15368 jobs are not security CodeQL/App 57789 evidence. The +distinct active security producer is workflow **280935553**, +`dynamic/github-code-scanning/codeql`; its head-filtered run inventory reported +zero runs for that #1174 head. The security/default-setup/analysis and rule-suite +APIs remain 403, so the full reason for the blocked merge is not established. +An authorized security administrator must inspect the configured scanner and +actual PR/current-main results through the supported CodeQL setup path, then +resolve its protection binding if needed. GitHub distinguishes +[analysis jobs from code-scanning results](https://docs.github.com/en/code-security/how-tos/manage-security-alerts/manage-code-scanning-alerts/triage-alerts-in-pull-requests). +No scanner dispatch, rerun, settings change, fake native Actions check or +protection change is authorized by this audit. Ordinary protected merges and +independent exact-head review remain required. + +### Concrete operator setup + +Environment/branch-policy and ruleset changes require repository +[Administration/write capability](https://docs.github.com/en/rest/deployments/environments#create-or-update-an-environment). Repository variables require Variables/write; +environment variables/secrets require Environments/write. Legacy run retirement +requires Actions/write; credential retirement requires the owning App/PyPI +administrator. None of these capabilities is granted to acceptance jobs. + +For **each** of `release-proposal` and `release-publish`, an authorized operator +can use this reviewed environment request body, then add the branch policy: + +```json +{ + "wait_timer": 0, + "prevent_self_review": true, + "can_admins_bypass": false, + "reviewers": [{"type": "User", "id": 134922}], + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } +} +``` + +```bash +# Operator only, after resolving the recorded 403; do not run from acceptance. +gh api --method PUT repos/adcontextprotocol/adcp-client-python/environments/release-proposal --input reviewed-environment.json +gh api --method POST repos/adcontextprotocol/adcp-client-python/environments/release-proposal/deployment-branch-policies -f name=main -f type=branch +# Repeat those two requests for release-publish, then GET both environments +# and all branch-policy pages to verify the exact settings and no extra policy. +``` + +Reviewer ID 134922 is `bokelley`. This workspace's API actor is also `bokelley`; +with self-review prohibited, a dispatch by that actor needs a different authorized +reviewer. Alternatively a different authorized actor can dispatch for that +reviewer. Do not disable self-review protection to resolve the mismatch. + +Install a **dedicated proposal App** only on `adcp-client-python`, with Contents +and Pull requests write and no main-rule/tag-creation bypass. Store its ID as +`RELEASE_PROPOSAL_APP_ID` and its private key as +`RELEASE_PROPOSAL_APP_PRIVATE_KEY` only in `release-proposal`, using the secrets +UI or encrypted environment-secret endpoint. Never copy the shared IPR App key +or print a key in a command, log, PR or chat. Verify the installation repository +selection, permissions and environment metadata before dispatch. + +In the existing PyPI `adcp` project's **Publishing** settings, verify a GitHub +[Trusted Publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) +with owner `adcontextprotocol`, repository `adcp-client-python`, +workflow filename `release-publish.yml`, and environment `release-publish`. +The new workflow uses protected-environment OIDC and attestations, never +`PYPY_API_TOKEN`. Retire legacy trust/credentials only through the separate +historical audit below. Verify tag creation permits the designated publisher +but excludes the proposal App, and retain separate no-update/no-delete tag +protection; do not grant administrator bypass. + +Complete integration and fresh CI **before** applying the no-bypass main freeze. +Then record the exact ruleset revision/target attestation in repository variable +`RELEASE_MAIN_FREEZE`. Keep `RELEASE_PUBLICATION_ENABLED` false until final +installed foundation acceptance, independent review, and operator cutover are +complete. The proposal and release-PR merge remain separate windows: drain and +unfreeze for the ordinary protected merge, then freeze and attest the new SHA. ## Disable, drain and retire historical definitions @@ -187,10 +393,50 @@ These actions require separate operator authorization; none are performed by the guard or its implementation PR. If historical writers cannot be retired, **do not enable the new publisher**. This is an external deployment blocker. +At the 2026-09-22 audit, all 718 retained legacy runs were terminal, but **69** +were still inside the rerun window. The latest is `34921539723`, created at +`2026-09-15T02:31:57Z`, source `3e76aa54623529a3dda01cd690b8a5c287c75641`. +Without authorized retirement, the guard rejects until **after +2026-10-15T02:31:57Z**, provided no newer invocation exists. + +The narrow retirement sequence for an authorized operator is: + +1. Re-inventory every page of workflow 204238826 runs while it stays disabled; + drain any newly nonterminal run and repeat the inventory. +2. Before deletion, retain each eligible run's metadata, every attempt/job log, + source workflow at its recorded SHA, artifact IDs/digests/bytes where still + available, and published package/release provenance/attestations. Record + unavailable or expired evidence explicitly. Keep checksums outside Actions + retention; deleting the run must not destroy the only audit copy. +3. Audit the reachable old PyPI credentials, App keys and writable job tokens. + Have the credential owners retire/isolate those old capabilities without + breaking the central IPR signature recorder or the new dedicated proposal App. +4. Only after evidence preservation and separate authorization, delete the + enumerated rerunnable legacy runs, or wait out their original 30-day windows. + Do not delete unrelated CI, tags, releases, package files or attestations. +5. Re-read the full run inventory and disabled state. The guard must find no + nonterminal/recent legacy run before the new publisher can be exposed. + +Read-only preparation uses the existing APIs, not a new archive protocol: + +```bash +gh api --paginate repos/adcontextprotocol/adcp-client-python/actions/workflows/204238826/runs > legacy-runs.json +gh api repos/adcontextprotocol/adcp-client-python/actions/runs/34921539723 > legacy-run-34921539723.json +gh api repos/adcontextprotocol/adcp-client-python/actions/runs/34921539723/attempts/1/logs > legacy-run-34921539723-attempt-1.zip +gh api --paginate repos/adcontextprotocol/adcp-client-python/actions/runs/34921539723/artifacts > legacy-run-34921539723-artifacts.json +gh api repos/adcontextprotocol/adcp-client-python/actions/workflows/204238826 --jq .state +``` + +Enumerate all attempts and eligible runs from the fresh inventory; the example +run is not the complete retirement list. No deletion or credential mutation is +performed by this follow-up. + ## Proposal, release merge, final validation and publication -1. Finish the reporting stack and #1172, then merge this independently reviewed - guard last. Leave the legacy workflow disabled. Record implementation commit +1. Finish the approved foundation/rc.4 stack and frozen installed harness, then + merge the independently reviewed readiness correction last. #1172/#1182 remain + open for their later service/provisional-read rollout. Leave the legacy + workflow disabled. Record implementation commit `M_impl`, its successful main-push CI run/attempt, and independent review. 2. Complete proposal prerequisites, freeze main, and dispatch `release-proposal.yml` on ref `main` with those explicit identities. Review `acceptance.json` and the diff --git a/docs/reporting-release-notes.md b/docs/reporting-release-notes.md index 99203788e..935c20714 100644 --- a/docs/reporting-release-notes.md +++ b/docs/reporting-release-notes.md @@ -132,6 +132,26 @@ Activation preserves epoch-zero work identities and permanently quarantined readiness events. It does not promote old work or backfill an external idempotency history. Only new qualified work enters the production epoch. +## Release commit-policy rollout + +The #1201 guard validates conventional messages for main integrations after +published beta.15, including the conventional message and footer stored in a +normal two-parent GitHub merge. Future breaking commits require a +`BREAKING CHANGE:` footer. Historical IPR checks retain the same beta.15 floor. + +The only historical footer exceptions are bound to these exact commits and +committed conventional subjects: + +- #1174 squash `6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac`: + `fix(reporting)!: scope configuration generations by account (#1174)`. +- #1192 merge `34c8f6d929aeac3407e2f595104a8e903e572623`: + `feat(reporting)!: enable production tier status and ownership` in its merge body. + +Their breaking titles and PR migration notes retain the release signals; no +date-based or moving-history exemption applies. The account-qualified generation +migration and production drain/migrate/activate sequence above still apply. +New commits with either subject still require a breaking footer. + ## Operational limits - Positive schema-proof caches reduce repeated catalog discovery. They do not diff --git a/scripts/check_main_policies.py b/scripts/check_main_policies.py new file mode 100644 index 000000000..cdee3cd89 --- /dev/null +++ b/scripts/check_main_policies.py @@ -0,0 +1,234 @@ +"""Real read-only policy checks for PRs and the integrated main source. + +GitHub Actions supplies the check-run identity. This script never creates a +status/check, records an agreement, or interprets another context as agreement. +The central IPR policy binds the PR author's numeric GitHub identity, not commit +email addresses: adcontextprotocol/adcp@82a671607c92945f0fec513c4375af583fdea914, +scripts/ipr/{check-and-record,signatures}.mjs and signatures/README.md. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any + +from scripts.release_gate import REPOSITORY, SHA, GitHub, require, timestamp + +ROOT = Path(__file__).resolve().parent.parent +LEDGER_REPOSITORY = "adcontextprotocol/adcp" +# Last published source before the guarded release path. This immutable floor +# never follows a tag or moves forward automatically: every subsequent main +# integration is checked again, including the complete staged foundation stack. +PUBLISHED_BASE = "3e76aa54623529a3dda01cd690b8a5c287c75641" # v8.0.0-beta.15 +CONVENTIONAL = re.compile( + r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)" + r"(?:\([^()\n]+\))?(?P!)?: (?P\S.*)$" +) +GITHUB_MERGE = re.compile(r"Merge pull request #[1-9][0-9]* from [^\s/]+/\S+") +# These already-merged commits predate the guard. Their breaking subjects +# and reviewed PR migration notes retain the release signal. No other commit, +# including one with the same subject, inherits these historical exceptions. +HISTORICAL_BREAKING_SUBJECTS = { + "6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac": ( + "fix(reporting)!: scope configuration generations by account (#1174)" + ), + "34c8f6d929aeac3407e2f595104a8e903e572623": ( + "feat(reporting)!: enable production tier status and ownership" + ), +} + + +def git(*arguments: str) -> str: + return subprocess.check_output(["git", *arguments], cwd=ROOT, text=True).strip() + + +def main_commits(target: str) -> list[str]: + require(SHA.fullmatch(target), "main policy needs an exact commit SHA") + # Require the floor on the first-parent chain, rather than accepting a + # side-branch ancestor that would silently omit integrations. + history = git("rev-list", "--first-parent", target).splitlines() + require(PUBLISHED_BASE in history, "published policy floor is missing from main history") + commits = list(reversed(history[: history.index(PUBLISHED_BASE)])) + require(commits, "no integrations after the published policy floor") + return commits + + +def validate_message(message: str, *, commit_sha: str | None = None) -> None: + subject, _, body = message.partition("\n") + match = CONVENTIONAL.fullmatch(subject) + require(match is not None, "commit subject is not a conventional commit") + assert match is not None + # GitHub integration subjects may include the PR number. Parentheses/quotes in + # the actual description remain forbidden by the repository's parser rule. + description = re.sub(r" \(#[1-9][0-9]*\)$", "", match["description"]) + require(description and not any(c in description for c in '()"'), "unsafe release description") + historical_footer_exception = ( + commit_sha is not None and HISTORICAL_BREAKING_SUBJECTS.get(commit_sha) == subject + ) + if match["breaking"] and not historical_footer_exception: + require( + re.search(r"(?m)^BREAKING(?: CHANGE|-CHANGE): \S.+$", body), + "breaking subject requires its BREAKING CHANGE footer in the actual commit", + ) + + +def validate_commit(sha: str) -> None: + parents, _, message = git("show", "-s", "--format=%P%n%B", sha).partition("\n") + subject, _, body = message.partition("\n") + if len(parents.split()) == 2 and GITHUB_MERGE.fullmatch(subject): + # GitHub's default merge subject is a wrapper. The conventional PR + # message is stored in the merge body; validate those committed bytes, + # including any breaking footer, rather than a mutable current PR title. + message = body.lstrip("\n") + validate_message(message, commit_sha=sha) + + +def event_context() -> tuple[str, str, dict[str, Any]]: + require(os.environ["GITHUB_REPOSITORY"] == REPOSITORY, "wrong policy repository") + target = os.environ["GITHUB_SHA"] + require(SHA.fullmatch(target), "policy needs an exact checkout SHA") + require(git("rev-parse", "HEAD") == target, "policy checkout does not match the event") + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + kind = os.environ["GITHUB_EVENT_NAME"] + if kind == "push": + require(os.environ["GITHUB_REF"] == "refs/heads/main", "policy push must target main") + require(os.environ["GITHUB_WORKFLOW_SHA"] == target, "main workflow is from another SHA") + require(event["after"] == target and event["ref"] == "refs/heads/main", "wrong push target") + require(not event["deleted"] and not event["forced"], "deleted or forced main push") + else: + require(kind == "pull_request", "unsupported policy event") + pr = event["pull_request"] + require(pr["base"]["repo"]["full_name"] == REPOSITORY, "wrong PR repository") + require(pr["base"]["ref"] == "main", "PR must target main") + require( + os.environ["GITHUB_REF"] == f"refs/pull/{pr['number']}/merge", + "policy must check the PR merge ref", + ) + return kind, target, event + + +def conventional(kind: str, target: str, event: dict[str, Any]) -> dict[str, Any]: + if kind == "push": + commits = main_commits(target) + else: + pr = event["pull_request"] + validate_message(pr["title"] + "\n\n" + (pr["body"] or "")) + head, base = pr["head"]["sha"], pr["base"]["sha"] + require(SHA.fullmatch(head) and SHA.fullmatch(base), "invalid PR source identities") + fork = git("merge-base", base, head) + # Only real merge commits are excluded from individual PR commits. + # A single-parent commit named 'Merge ...' is not a bypass. + commits = git("rev-list", "--reverse", "--no-merges", f"{fork}..{head}").splitlines() + require(commits, "PR has no commits to validate") + for sha in commits: + try: + validate_commit(sha) + except RuntimeError as error: + raise RuntimeError(f"invalid integrated commit {sha}: {error}") from error + return {"target_sha": target, "commits": commits, "policy": "conventional-commits"} + + +def signatures(api: GitHub) -> tuple[str, dict[int, dict[str, Any]]]: + remote = f"/repos/{LEDGER_REPOSITORY}" + ref = api.request(remote + "/git/ref/heads/main") + sha = ref["object"]["sha"] + require(ref["object"]["type"] == "commit" and SHA.fullmatch(sha), "invalid ledger revision") + blob = api.request(remote + f"/contents/signatures/ipr-signatures.json?ref={sha}") + require(blob["encoding"] == "base64", "unreadable central agreement ledger") + ledger = json.loads(base64.b64decode("".join(blob["content"].split()), validate=True)) + entries = ledger["signedContributors"] + require(isinstance(entries, list) and entries, "missing canonical signature records") + signers: dict[int, dict[str, Any]] = {} + for entry in entries: + identifier = entry["id"] + require(type(identifier) is int and identifier > 0, "invalid signer GitHub identity") + require(identifier not in signers, "ambiguous signer GitHub identity") + require(entry["name"] and entry["method"], "incomplete signature record") + timestamp(entry["created_at"]) + signers[identifier] = entry + return sha, signers + + +def author_agreement(pr: dict[str, Any], signers: dict[int, dict[str, Any]]) -> dict[str, Any]: + user = pr["user"] + require(isinstance(user, dict), "PR has no authenticated author") + identifier = user["id"] + require(type(identifier) is int and identifier > 0, "PR has no authenticated author ID") + # Follow the canonical bot exemption using the API's account type. A + # missing/deleted author or a suggestive login string is not a bot identity. + if user["type"] == "Bot": + return {"author_id": identifier, "agreement": "canonical-bot-exemption"} + require(user["type"] == "User", "unknown PR author type") + require(identifier in signers, f"PR #{pr['number']} author ID {identifier} has not signed") + entry = signers[identifier] + if pr.get("merged_at"): + require( + timestamp(entry["created_at"]) <= timestamp(pr["merged_at"]), + "agreement was recorded after the contribution merged", + ) + return {"author_id": identifier, "agreement": entry["created_at"]} + + +def validate_pr(pr: dict[str, Any]) -> None: + require(pr["base"]["ref"] == "main", "contribution targets another branch") + require(pr["base"]["repo"]["full_name"] == REPOSITORY, "foreign contribution repository") + + +def ipr(api: GitHub, kind: str, target: str, event: dict[str, Any]) -> dict[str, Any]: + ledger_sha, signers = signatures(api) + contributions = [] + if kind == "push": + for sha in main_commits(target): + candidates = [ + pr + for pr in api.pages(f"commits/{sha}/pulls") + if pr["merge_commit_sha"] == sha + and pr["merged_at"] + and pr["base"]["ref"] == "main" + and pr["base"]["repo"]["full_name"] == REPOSITORY + ] + require(len(candidates) == 1, f"no unique merged contribution for main commit {sha}") + pr = api.repo(f"pulls/{candidates[0]['number']}") + validate_pr(pr) + require(pr["merged"] is True and pr["merge_commit_sha"] == sha, "wrong merged PR") + require(pr["merged_at"], "missing contribution merge time") + contributions.append({"sha": sha, "pr": pr["number"], **author_agreement(pr, signers)}) + else: + expected = event["pull_request"] + pr = api.repo(f"pulls/{expected['number']}") + validate_pr(pr) + require(pr["head"]["sha"] == expected["head"]["sha"], "PR head changed during policy check") + require(pr["state"] == "open" and not pr["merged"], "PR is no longer open") + contributions.append( + {"sha": pr["head"]["sha"], "pr": pr["number"], **author_agreement(pr, signers)} + ) + return { + "policy": "IPR Policy / Signature", + "target_sha": target, + "published_base": PUBLISHED_BASE, + "ledger_repository": LEDGER_REPOSITORY, + "ledger_sha": ledger_sha, + "contributions": contributions, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("policy", choices=("ipr", "conventional")) + args = parser.parse_args() + kind, target, event = event_context() + if args.policy == "ipr": + result = ipr(GitHub(os.environ["GH_TOKEN"]), kind, target, event) + else: + result = conventional(kind, target, event) + print(json.dumps(result, sort_keys=True, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 7c0374478..9bb5611f2 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -34,8 +34,10 @@ } # A protection edit must not silently remove the runtime CI floor. Additional # required checks are discovered live and must also pass, without skip/neutral -# exceptions (including the currently PR-only policy checks). +# exceptions. The real policy jobs must belong to the selected main CI attempt. CI_FLOOR = { + "IPR Policy / Signature", + "Validate conventional commit format", "Test Python 3.10", "Test Python 3.11", "Test Python 3.12", @@ -283,7 +285,11 @@ def validate_freeze(api: GitHub, rules: list[dict[str, Any]], target: str, now: def validate_checks( - rules: list[dict[str, Any]], checks: list[dict[str, Any]], target: str, now: datetime + rules: list[dict[str, Any]], + checks: list[dict[str, Any]], + target: str, + now: datetime, + branch_protection: dict[str, Any], ) -> None: required = [ check @@ -292,8 +298,22 @@ def validate_checks( for check in rule["parameters"]["required_status_checks"] ] require(CI_FLOOR <= {check["context"] for check in required}, "protected CI floor is missing") + # The branch summary exposes classic requirements omitted by the ruleset + # endpoint. Never infer that a different job name or App satisfies one. + contexts = branch_protection.get("contexts") + bindings = branch_protection.get("checks") + if not isinstance(contexts, list) or not isinstance(bindings, list): + raise ReleaseRejectedError("branch protection check inventory is unreadable") + require( + set(contexts) == {check["context"] for check in bindings}, + "branch protection App binding inventory is incomplete", + ) + required.extend( + {"context": check["context"], "integration_id": check.get("app_id")} for check in bindings + ) for expected in required: - require(expected["integration_id"] is not None, "required check has no trusted App binding") + app_id = expected["integration_id"] + require(type(app_id) is int and app_id > 0, "required check has no trusted App binding") matches = [ check for check in checks @@ -331,6 +351,11 @@ def gate(api: GitHub, context: Context, now: datetime | None = None) -> dict[str if context.operation == "publish": validate_legacy_retirement(api, now) require(api.repo("git/ref/heads/main")["object"]["sha"] == context.target, "stale main target") + branch = api.repo("branches/main") + require( + branch["name"] == "main" and branch["commit"]["sha"] == context.target, + "branch protection summary belongs to another target", + ) rules = api.pages("rules/branches/main") validate_freeze(api, rules, context.target, now) validate_environment(api, context.operation) @@ -362,7 +387,9 @@ def gate(api: GitHub, context: Context, now: datetime | None = None) -> dict[str ) fresh(job["completed_at"], now) checks = api.pages(f"commits/{context.target}/check-runs?filter=latest", "check_runs") - validate_checks(rules, checks, context.target, now) + validate_checks( + rules, checks, context.target, now, branch["protection"]["required_status_checks"] + ) job_checks = {job["check_run_url"] for job in jobs} for check in checks: if check["name"] in CI_FLOOR: diff --git a/tests/fixtures/release_main_history.json b/tests/fixtures/release_main_history.json new file mode 100644 index 000000000..374292d50 --- /dev/null +++ b/tests/fixtures/release_main_history.json @@ -0,0 +1,216 @@ +{ + "published_base": "3e76aa54623529a3dda01cd690b8a5c287c75641", + "target": "2bfbca4b9c7505e5f7dc37d7c7f17bdb10ecfdd5", + "commits": [ + { + "sha": "f6e9c15333db8e657ba96a79d806194dfc0e0447", + "parents": [ + "3e76aa54623529a3dda01cd690b8a5c287c75641" + ], + "message": "fix(types): restore targeting overlay input compatibility (#1190)\n\n* fix(types): restore targeting overlay input compatibility\n\n* test(types): normalize import style in export guard\n\n* fix(types): keep targeting compatibility runtime-only\n\n* fix(types): preserve targeting validation error paths\n\n* fix(types): preserve targeting JSON validation diagnostics" + }, + { + "sha": "a9bc11c94e7f188d9a442def20c64f76a11b0cab", + "parents": [ + "f6e9c15333db8e657ba96a79d806194dfc0e0447" + ], + "message": "fix(release): gate publishing on accepted main artifacts (#1200)\n\n* fix(release): gate publishing on accepted main artifacts\n\n* test(release): initialize the fake GitHub client" + }, + { + "sha": "6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac", + "parents": [ + "a9bc11c94e7f188d9a442def20c64f76a11b0cab" + ], + "message": "fix(reporting)!: scope configuration generations by account (#1174)\n\n* fix(reporting): scope configuration generations by account\n\n* fix(reporting): rotate in-memory period-close leases across accounts\n\nInMemoryReportingLedgerStore.lease_period_close handed back the first\nleasable generation in insertion order, and release_period_close puts\nthat generation straight back into the free pool.\nReportingProducer.run_worker releases in a finally, so a worker loop\nre-leased the same generation on every turn and never closed a period\nfor any other account. The SQL store orders by lease_expires_at NULLS\nFIRST, and an update to that indexed column sends a released row to the\nback of the queue, so the two stores only diverged once two accounts\ncould hold the same delivery_config_id -- which is what this branch\nenables.\n\nRank leasable generations the way the SQL store does, unheld before\nexpired and oldest expiry first, then break the tie by whichever\ngeneration went longest without a turn. The shared memory/PostgreSQL\nsuite now asserts that a worker releasing every turn reaches all three\naccounts; it failed on memory and passed on PostgreSQL before this\nchange.\n\nCo-Authored-By: Claude Opus 5 (1M context) \n\n---------\n\nCo-authored-by: Claude Opus 5 (1M context) " + }, + { + "sha": "0164f907f558fc628fc913ff40bc9574856393ec", + "parents": [ + "6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac", + "abc84746f43f7f80ebfafe21c2d1bfed54c8dd3c" + ], + "message": "fix(reporting): freeze currency on reporting obligations (#1175)\n\nMerge the reviewed currency-obligation change and its test-only optional PostgreSQL import repair, preserving the reviewed branch ancestry.\n\nFresh CI 35809709329 attempt 1 passed all 16 jobs on the exact candidate and GitHub test merge. Ladon approved the exact head; all review conversations are resolved. Genuine CodeQL and the actual IPR check and IPR Policy / Signature status passed." + }, + { + "sha": "fba5515825b671c29c969e67ed982a606eab5208", + "parents": [ + "0164f907f558fc628fc913ff40bc9574856393ec" + ], + "message": "feat(reporting): automate source settling windows (#1209)" + }, + { + "sha": "25c297d663e70287f6abd8a0f954e17a8ab4d340", + "parents": [ + "fba5515825b671c29c969e67ed982a606eab5208" + ], + "message": "feat(reporting): add adapter-first reliable reporting service (#1210)" + }, + { + "sha": "4823dfff7ae94924419a745e0152547460dcdf42", + "parents": [ + "25c297d663e70287f6abd8a0f954e17a8ab4d340", + "d523210d8c1f3f7b8d4ad6d7e4edbd827ee21b84" + ], + "message": "feat(reporting): persist managed delivery reconciliation records (#1176)\n\nPreserve the reviewed reconciliation-records branch history, its optional PostgreSQL test repairs, and the two ordinary refresh merges composing source settling and the adapter-first reporting service.\n\nFresh CI 35816493551 attempt 1 passed all 16 jobs on the exact current-base composition. Fresh Ladon approval 5286749759 binds d523210d in both API and embedded decision; all 16 review threads are resolved. Genuine CodeQL, actual IPR verification, and IPR Policy / Signature passed.\n\nUse the standing administrator authorization only for the missing literal check / check requirement. No ordinary CI, review or genuine security finding is waived. Installed-artifact, interoperability and release qualification remain separate." + }, + { + "sha": "5dc746a89aebcfc4d9158632cca60668d27c45e9", + "parents": [ + "4823dfff7ae94924419a745e0152547460dcdf42", + "c6573899d0249c9718096cd07b2da32f28cfc500" + ], + "message": "test(reporting): integrate currency and metric evidence (#1177)\n\nIntegrate the reviewed metric-evidence and currency foundation with current main's settling windows and reporting service. Preserve immutable publication/reconciliation evidence and the six original InlineFetchResult positional fields; require explicit keywords for added evidence fields so currency cannot silently bind to a settling timestamp. Keep test-harness writes outside assertions and document the adopter migration.\n\nCI 35820625906 attempt 1 passed all 16 jobs on the exact head and merge tree. Fresh Ladon approval and the independent composition/delta review bind c6573899; all review conversations are resolved. Genuine CodeQL and actual IPR verification passed. The standing administrative exception applies only to the missing literal check / check binding.\n\nBREAKING CHANGE: InlineFetchResult currency and provisional_until must be passed by keyword. The six earlier positional arguments retain their order; the source-adapter guide includes the migration example." + }, + { + "sha": "17ee407ae3978c8a2bb54437287afbf9dafb8130", + "parents": [ + "5dc746a89aebcfc4d9158632cca60668d27c45e9", + "d08e569730bac911352a54b34b74762a1de4ae41" + ], + "message": "feat(reporting): add transactional notification outbox (#1178)\n\nAdd opt-in transactional reporting notifications and Managed-readiness delivery, with durable subscriber expansion, lease-fenced dispatch, and immutable prepared delivery identities. Retain main's currency, evidence and settling behavior, including strict checkpoint-schema readiness. Make catalog fingerprints portable across database locales while preserving all 102 frozen values and exact validation.\n\nCI 35828007052 attempt 1 passed all 16 jobs bound to head d08e5697: 15 jobs checked out GitHub's merge composition 5fc38323; one aggregate had no checkout. PostgreSQL passed 1245 tests. Fresh Ladon approval and the independent composition/delta reviews bind the exact source history; all 19 review conversations are resolved. Genuine CodeQL and actual IPR verification passed. The standing administrative exception applies only to the missing literal check / check binding; no ordinary CI or review failure is waived. The original 9fc PostgreSQL failure remains preserved separately.\n\nThis is partial #1168A: ledger/readiness notifications and an ordered status-dirty handoff. Complete status projection and webhook-activity capability remain in the following stack step. Source integration does not confer released-artifact, main-artifact, or #1199 acceptance." + }, + { + "sha": "0f34c666ac1961e9832fce43ef0ef6937b3c1dde", + "parents": [ + "17ee407ae3978c8a2bb54437287afbf9dafb8130", + "d0d74b9c90c3eea1758da5e794c33e4f7a6fc67e" + ], + "message": "feat(reporting): expose durable webhook activity (#1183)\n\nRecord durable, account-scoped reporting webhook attempts and expose their activity through the opt-in account projection. Preserve pending reservations across uncertain delivery, enforce principal and account boundaries, and validate required schema objects individually.\n\nCompose the feature with integrated reporting foundations, preserve strict checkpoint validation, and qualify rolling behavior against the integrated A baseline. Keep PostgreSQL execution bounded in core/process lanes with a strict required aggregate and explicit token permissions. Historical pre-integration compatibility and main artifact/release qualification remain outside this source merge." + }, + { + "sha": "967b6e286301d7e5d089aea6fdbb90bea8ee5a16", + "parents": [ + "0f34c666ac1961e9832fce43ef0ef6937b3c1dde", + "81a1c5b3aa8711229f0dedcad06e43a0cfc39871" + ], + "message": "Merge pull request #1185 from adcontextprotocol/conductor/reporting-status-notifications-1168c\n\nfeat(reporting): project durable status notifications" + }, + { + "sha": "5487f2bdef23c5102118b305be9e868228f6ce61", + "parents": [ + "967b6e286301d7e5d089aea6fdbb90bea8ee5a16", + "58c82997ef82b8b5a5996eaa070c142fa488ba87" + ], + "message": "Merge pull request #1186 from adcontextprotocol/conductor/1167b1-materializer-contracts\n\nfeat(reporting): add destination verification and strict revision selection" + }, + { + "sha": "3fd62121c96a074e3ea458c30c5224d6a586f169", + "parents": [ + "5487f2bdef23c5102118b305be9e868228f6ce61", + "8ea0dbe46aafa356a40bb256dbb97316487c2468" + ], + "message": "Merge pull request #1187 from adcontextprotocol/conductor/1167b2-durable-managed-reporting\n\nfeat(reporting): materialize revisions with durable fenced work" + }, + { + "sha": "09fd87f79a746665d828dea66b3a1dd9d1fc189e", + "parents": [ + "3fd62121c96a074e3ea458c30c5224d6a586f169", + "624c1001ed42a210462ddb6de76806f9c1a81fc8" + ], + "message": "Merge pull request #1188 from adcontextprotocol/conductor/reporting-receipt-ingress-b22\n\nfeat(reporting): ingest receipts with authenticated durable replay" + }, + { + "sha": "2d777ace7b4bf8be519ce0abd4fd0a25ed4f1da7", + "parents": [ + "09fd87f79a746665d828dea66b3a1dd9d1fc189e", + "a35882251f91d9050f2a0cf98e226a31442bdafa" + ], + "message": "Merge pull request #1215 from adcontextprotocol/conductor/reporting-frozen-feed-integrated\n\nfeat(reporting): persist authorized frozen reporting feeds" + }, + { + "sha": "e16eb8cf3074cabd45aab42840950f05ad6d2b43", + "parents": [ + "2d777ace7b4bf8be519ce0abd4fd0a25ed4f1da7", + "ab12e1511058f0a9e75a8228f650d9a302423640" + ], + "message": "Merge pull request #1191 from adcontextprotocol/conductor/reporting-schema-proof-receipt-diagnostics-hardening\n\nfix(reporting): cache schema proofs and retain safe receipt diagnostics" + }, + { + "sha": "34c8f6d929aeac3407e2f595104a8e903e572623", + "parents": [ + "e16eb8cf3074cabd45aab42840950f05ad6d2b43", + "decfb4eb53845c22b019466b4167293f6c6181c7" + ], + "message": "Merge pull request #1192 from adcontextprotocol/conductor/reporting-production-tier-capabilities-b24\n\nfeat(reporting)!: enable production tier status and ownership" + }, + { + "sha": "940c95e0c2d93758ed334b3edff59cbe933362d4", + "parents": [ + "34c8f6d929aeac3407e2f595104a8e903e572623", + "1a0c7104c5c3a4c61aa35f78759c6014b1f984df" + ], + "message": "Merge pull request #1193 from adcontextprotocol/conductor/reporting-adcp-rc4-adoption\n\nfeat(reporting): integrate signed rc6 fixtures and lease progress" + }, + { + "sha": "5fd54334e92057c9d7457a7aa87b5dd7d1e49330", + "parents": [ + "940c95e0c2d93758ed334b3edff59cbe933362d4", + "8d6eba964b86c209a26df5decfef4f47d1c0a576" + ], + "message": "Merge pull request #1208 from adcontextprotocol/conductor/reporting-corrections-integration-v2\n\nfix(reporting): integrate reviewed reporting and signing corrections" + }, + { + "sha": "6cd1929d96be5ec211ce0729d140d8484f0e8e70", + "parents": [ + "5fd54334e92057c9d7457a7aa87b5dd7d1e49330" + ], + "message": "docs(reporting): document upgrade boundaries and guarded release steps (#1216)\n\n* docs(reporting): document upgrade boundaries and guarded release steps\n\n* docs(reporting): distinguish ledger retention from source recovery" + }, + { + "sha": "a9719c6ba78f68b8a83ee143a7eff58989978030", + "parents": [ + "6cd1929d96be5ec211ce0729d140d8484f0e8e70" + ], + "message": "fix(reporting): settle service lifecycle before resource cleanup (#1218)\n\n* fix(reporting): settle service lifecycle before resource cleanup\n\n* fix(reporting): isolate worker failures and preserve error callbacks" + }, + { + "sha": "e77a401309be2397347cc4bd6bb5b6d45ddb33f1", + "parents": [ + "a9719c6ba78f68b8a83ee143a7eff58989978030" + ], + "message": "feat(reporting): persist buyer receipt submission intents (#1219)\n\n* feat(reporting): persist buyer receipt submission intents\n\n* fix(reporting): bound buyer intent validation and verify checks\n\n* fix(ci): allow time for Postgres packaging coverage\n\nKeep the full core selection with a 30-minute job budget and preserve the process lane at 15 minutes. Address buyer submission review comments on redacted errors and test assertions." + }, + { + "sha": "c67b97e02afcb387a5c9b6c06caa0904d43efcd8", + "parents": [ + "e77a401309be2397347cc4bd6bb5b6d45ddb33f1" + ], + "message": "feat(reporting): preserve raw adjustment evidence for receipts (#1220)\n\n* feat(reporting): capture raw adjustment evidence and build receipts\n\n* fix(reporting): enforce adjustment receipt validation boundaries\n\n* fix(reporting): explain adjustment validation fallbacks" + }, + { + "sha": "7c5a12397a27041bd76fce07e9ffafec4efa2295", + "parents": [ + "c67b97e02afcb387a5c9b6c06caa0904d43efcd8" + ], + "message": "fix(server): settle lifespan cleanup after startup failures (#1223)\n\n* fix(server): settle lifespan cleanup after startup failures\n\n* fix(server): preserve startup errors through cancellation" + }, + { + "sha": "2a45bafddd28afe9e6013a715289c2e1d9b208db", + "parents": [ + "7c5a12397a27041bd76fce07e9ffafec4efa2295" + ], + "message": "feat(reporting): persist scheduled provisional observations (#1221)\n\n* feat(reporting): persist scheduled provisional observations\n\n* fix(reporting): validate provisional storage and compose publishers\n\n* fix(reporting): use protocol stub bodies\n\n* test(reporting): await late-account turn and scheduled reread\n\n* ci(reporting): allow core Postgres lane to finish installed cases\n\n* ci(reporting): allow installed PostgreSQL contract to finish\n\n* ci(reporting): allow coverage matrix to finish\n\n* ci(reporting): preserve coverage timeout headroom" + }, + { + "sha": "d300c61a8812400363b8cd5812f88fa2b66a6a02", + "parents": [ + "2a45bafddd28afe9e6013a715289c2e1d9b208db" + ], + "message": "fix(reporting): validate buyer frozen histories (#1222)\n\n* fix(reporting): validate buyer frozen histories\n\n* fix(reporting): preserve incomplete buyer read evidence\n\n* fix(reporting): bound ambiguous history counts\n\n* test(reporting): cover PG finality denominator proof\n\n* test(reporting): load frozen histories in publication checks\n\n* fix(reporting): check delivery-only adjustment evidence" + }, + { + "sha": "02a1961d1966f2a1fe98eddee9f11edfe2a0fd75", + "parents": [ + "d300c61a8812400363b8cd5812f88fa2b66a6a02" + ], + "message": "fix(reporting): reuse staged payloads across source observations (#1224)\n\n* fix(reporting): reuse staged payloads across source observations\n\n* docs(reporting): clarify memory staging loss and ledger reads\n\n* chore(ci): run staging checks against main\n\n* test(reporting): assert account-scoped staging refs\n\n* test(reporting): explain staging race settlement\n\n* fix(reporting): bound staging fsync to store root\n\n* fix(reporting): bound staging root durability sync\n\n* fix(reporting): verify staging ancestor boundary" + }, + { + "sha": "2bfbca4b9c7505e5f7dc37d7c7f17bdb10ecfdd5", + "parents": [ + "02a1961d1966f2a1fe98eddee9f11edfe2a0fd75" + ], + "message": "feat(reporting): compose provisional source lifecycle (#1225)\n\n* feat(reporting): compose provisional source lifecycle\n\n* style(reporting): wrap inline storage validation strings\n\n* test(reporting): bind B2.4 wheel check to historical modules\n\n* test(reporting): include service context in migration catalog\n\n* feat(reporting): enforce live source authorization at dispatch and publish\n\n* test(reporting): account for scheduled provisional refreshes\n\n* fix(reporting): keep discovery probes from revoking dispatch turns\n\n* refactor(reporting): drop superseded source authorization grant model\n\n* fix(reporting): share the source publication transaction with inline seals\n\n* test(reporting): narrow inline storage error assertions\n\n* fix(reporting): defer inline storage error imports to execution\n\n* test(reporting): authorize publication clock fixtures\n\n* fix(reporting): reject live source mapping changes\n\n* fix(ci): allow production gate cleanup headroom" + } + ] +} diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py new file mode 100644 index 000000000..a9533f58d --- /dev/null +++ b/tests/test_main_release_policies.py @@ -0,0 +1,449 @@ +"""Policy provenance and actual integrated-history regressions, without writes.""" + +from __future__ import annotations + +import base64 +import copy +import json +import subprocess +import urllib.error +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from scripts import check_main_policies as policies +from scripts.release_gate import ReleaseRejectedError + +LEDGER_SHA = "d" * 40 +BREAKING_SUBJECT = "fix(reporting)!: scope configuration generations by account (#1174)" +BREAKING_FOOTER = ( + "BREAKING CHANGE: generation_key returns ReportingConfigurationGenerationKey " + "instead of a two-tuple. Use its named fields." +) + + +@pytest.fixture +def history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list[str]: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + monkeypatch.setattr(policies, "ROOT", tmp_path) + policies.git("config", "user.name", "Policy Fixture") + policies.git("config", "user.email", "fixture@example.invalid") + commits = [] + for message in ( + "chore: published baseline", + "fix: first contribution", + BREAKING_SUBJECT + "\n\n" + BREAKING_FOOTER, + ): + policies.git("-c", "core.hooksPath=/dev/null", "commit", "--allow-empty", "-m", message) + commits.append(policies.git("rev-parse", "HEAD")) + monkeypatch.setattr(policies, "PUBLISHED_BASE", commits[0]) + return commits + + +class PolicyAPI(policies.GitHub): + def __init__(self, commits: list[str]) -> None: + super().__init__("fixture-token") + self.reads: list[str] = [] + self.records = [ + { + "id": 7, + "name": "signed-user", + "method": "pr_comment", + "created_at": "2026-04-01T00:00:00Z", + } + ] + self.data: dict[str, Any] = {} + for number, sha in enumerate(commits[1:], 1173): + pr = { + "number": number, + "base": {"ref": "main", "repo": {"full_name": policies.REPOSITORY}}, + "head": {"sha": "e" * 40}, + "merge_commit_sha": sha, + "merged_at": "2026-09-22T00:00:00Z", + "merged": True, + "state": "closed", + "user": {"id": 7, "type": "User", "login": "signed-user"}, + } + self.data[f"commits/{sha}/pulls?per_page=100&page=1"] = [pr] + self.data[f"pulls/{number}"] = pr + + def request(self, path: str, method: str = "GET", body: Any = None) -> Any: + assert method == "GET" and body is None, "policy must not write any evidence" + self.reads.append(path) + prefix = f"/repos/{policies.LEDGER_REPOSITORY}" + if path == prefix + "/git/ref/heads/main": + return {"object": {"type": "commit", "sha": LEDGER_SHA}} + assert path == prefix + f"/contents/signatures/ipr-signatures.json?ref={LEDGER_SHA}" + return { + "encoding": "base64", + "content": base64.b64encode( + json.dumps({"signedContributors": self.records}).encode() + ).decode(), + } + + def repo(self, suffix: str, method: str = "GET", body: Any = None) -> Any: + assert method == "GET" and body is None, "policy must not write any evidence" + self.reads.append(suffix) + return copy.deepcopy(self.data[suffix]) + + +def test_every_post_release_integration_has_real_agreement(history: list[str]) -> None: + api = PolicyAPI(history) + evidence = policies.ipr(api, "push", history[-1], {}) + assert evidence["target_sha"] == history[-1] + assert evidence["ledger_sha"] == LEDGER_SHA + assert [entry["sha"] for entry in evidence["contributions"]] == history[1:] + assert [entry["author_id"] for entry in evidence["contributions"]] == [7, 7] + assert not any("check-runs" in path or "status" in path for path in api.reads) + assert policies.conventional("push", history[-1], {})["commits"] == history[1:] + + +@pytest.mark.parametrize( + "case", + [ + "unsigned_earlier", + "renamed_login_wrong_id", + "unknown_author", + "fake_bot_login", + "late_signature", + "duplicate_signature", + "empty_ledger", + "wrong_base", + "wrong_repository", + "wrong_merge", + "not_merged", + "direct_push", + "ambiguous_pr", + ], +) +def test_main_agreement_cannot_be_substituted(history: list[str], case: str) -> None: + api = PolicyAPI(history) + earlier = api.data["pulls/1173"] + if case in {"unsigned_earlier", "renamed_login_wrong_id"}: + earlier["user"]["id"] = 8 + elif case == "unknown_author": + earlier["user"] = None + elif case == "fake_bot_login": + earlier["user"] = {"id": 8, "type": "User", "login": "github-actions[bot]"} + elif case == "late_signature": + api.records[0]["created_at"] = "2026-09-23T00:00:00Z" + elif case == "duplicate_signature": + api.records.append(dict(api.records[0])) + elif case == "empty_ledger": + api.records.clear() + elif case == "wrong_base": + earlier["base"]["ref"] = "other" + elif case == "wrong_repository": + earlier["base"]["repo"]["full_name"] = "fork/other" + elif case == "wrong_merge": + earlier["merge_commit_sha"] = "c" * 40 + elif case == "not_merged": + earlier["merged"] = False + elif case == "direct_push": + api.data[f"commits/{history[1]}/pulls?per_page=100&page=1"] = [] + else: + api.data[f"commits/{history[1]}/pulls?per_page=100&page=1"] *= 2 + with pytest.raises(ReleaseRejectedError): + policies.ipr(api, "push", history[-1], {}) + + +def test_signer_identity_survives_login_rename_and_real_bots_follow_policy( + history: list[str], +) -> None: + api = PolicyAPI(history) + api.data["pulls/1173"]["user"]["login"] = "renamed-user" + api.data["pulls/1174"]["user"] = {"id": 8, "type": "Bot", "login": "release-app[bot]"} + evidence = policies.ipr(api, "push", history[-1], {}) + assert evidence["contributions"][0]["author_id"] == 7 + assert evidence["contributions"][1]["agreement"] == "canonical-bot-exemption" + + +def test_all_pages_are_read_before_selecting_merged_pr(history: list[str]) -> None: + api = PolicyAPI(history) + path = f"commits/{history[1]}/pulls?per_page=100&page=" + actual = api.data[path + "1"] + api.data[path + "1"] = [{**actual[0], "merge_commit_sha": "c" * 40}] * 100 + api.data[path + "2"] = actual + policies.ipr(api, "push", history[-1], {}) + assert path + "2" in api.reads + + +def test_ledger_permission_failure_is_not_agreement( + history: list[str], monkeypatch: pytest.MonkeyPatch +) -> None: + api = PolicyAPI(history) + + def forbidden(*args: Any) -> Any: + raise urllib.error.HTTPError("https://api.github.com", 403, "forbidden", {}, None) + + monkeypatch.setattr(api, "request", forbidden) + with pytest.raises(urllib.error.HTTPError): + policies.ipr(api, "push", history[-1], {}) + + +@pytest.mark.parametrize("case", ["missing_floor", "floor_only", "non_sha"]) +def test_incomplete_main_history_rejects( + history: list[str], monkeypatch: pytest.MonkeyPatch, case: str +) -> None: + target = history[-1] + if case == "missing_floor": + monkeypatch.setattr(policies, "PUBLISHED_BASE", "c" * 40) + elif case == "floor_only": + target = history[0] + else: + target = "main" + with pytest.raises(ReleaseRejectedError): + policies.main_commits(target) + + +@pytest.mark.parametrize( + "message", + [ + "Merge abc into def", + "fix: bad (description)", + 'fix: bad "description"', + "fix: ", + BREAKING_SUBJECT, + BREAKING_SUBJECT + "\n\nMigration notes without a footer.", + ], +) +def test_nonconventional_or_lost_breaking_footer_rejects(message: str) -> None: + with pytest.raises(ReleaseRejectedError): + policies.validate_message(message) + + +def test_actual_main_message_is_checked_not_pr_title(history: list[str]) -> None: + policies.git( + "-c", + "core.hooksPath=/dev/null", + "commit", + "--allow-empty", + "--amend", + "-m", + BREAKING_SUBJECT, + ) + target = policies.git("rev-parse", "HEAD") + with pytest.raises(RuntimeError, match="BREAKING CHANGE footer"): + policies.conventional("push", target, {}) + + +@pytest.mark.parametrize("keep_footer", [True, False]) +def test_two_parent_main_merge_preserves_reviewed_ancestry_and_checks_actual_footer( + history: list[str], keep_footer: bool +) -> None: + policies.git("checkout", "-b", "reviewed-reporting", history[0]) + policies.git( + "-c", + "core.hooksPath=/dev/null", + "commit", + "--allow-empty", + "-m", + "feat: reviewed reporting", + ) + reviewed = policies.git("rev-parse", "HEAD") + policies.git("checkout", "-b", "integration", history[-1]) + message = BREAKING_SUBJECT + ("\n\n" + BREAKING_FOOTER if keep_footer else "") + policies.git( + "-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "reviewed-reporting", "-m", message + ) + target = policies.git("rev-parse", "HEAD") + assert policies.git("show", "-s", "--format=%P", target).split() == [history[-1], reviewed] + assert policies.main_commits(target) == [*history[1:], target] + # Policy checks the integrating PR's author for the complete contribution; + # it does not flatten or rewrite the reviewed branch's commit ancestry. + api = PolicyAPI([*history, target]) + assert len(policies.ipr(api, "push", target, {})["contributions"]) == 3 + if keep_footer: + assert policies.conventional("push", target, {})["commits"][-1] == target + else: + with pytest.raises(RuntimeError, match="BREAKING CHANGE footer"): + policies.conventional("push", target, {}) + + +@pytest.mark.parametrize( + ("body", "accepted"), + [ + ("feat: reviewed reporting", True), + (BREAKING_SUBJECT + "\n\n" + BREAKING_FOOTER, True), + ("", False), + ("Reporting changes without a conventional message", False), + (BREAKING_SUBJECT, False), + ], +) +def test_github_merge_checks_conventional_message_stored_in_commit( + history: list[str], body: str, accepted: bool +) -> None: + policies.git("checkout", "-b", "reviewed-reporting", history[0]) + policies.git( + "-c", "core.hooksPath=/dev/null", "commit", "--allow-empty", "-m", "feat: reporting" + ) + policies.git("checkout", "-b", "integration", history[-1]) + policies.git( + "-c", + "core.hooksPath=/dev/null", + "merge", + "--no-ff", + "reviewed-reporting", + "-m", + "Merge pull request #1175 from example/reviewed-reporting\n\n" + body, + ) + target = policies.git("rev-parse", "HEAD") + if accepted: + assert policies.conventional("push", target, {})["commits"][-1] == target + else: + with pytest.raises(RuntimeError): + policies.conventional("push", target, {}) + + +def test_single_parent_commit_cannot_impersonate_github_merge(history: list[str]) -> None: + policies.git( + "-c", + "core.hooksPath=/dev/null", + "commit", + "--allow-empty", + "-m", + "Merge pull request #1175 from example/reviewed-reporting\n\nfeat: reporting", + ) + with pytest.raises(RuntimeError, match="not a conventional commit"): + policies.conventional("push", policies.git("rev-parse", "HEAD"), {}) + + +@pytest.mark.parametrize( + ("commit_sha", "subject", "accepted"), + [ + ("6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac", BREAKING_SUBJECT, True), + ("a" * 40, BREAKING_SUBJECT, False), + (None, BREAKING_SUBJECT, False), + ( + "6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac", + "fix(reporting)!: different breaking change (#1174)", + False, + ), + ( + "34c8f6d929aeac3407e2f595104a8e903e572623", + "feat(reporting)!: enable production tier status and ownership", + True, + ), + ("a" * 40, "feat(reporting)!: enable production tier status and ownership", False), + (None, "feat(reporting)!: enable production tier status and ownership", False), + ("34c8f6d929aeac3407e2f595104a8e903e572623", BREAKING_SUBJECT, False), + ], +) +def test_historical_footer_exception_requires_exact_commit_and_subject( + commit_sha: str | None, subject: str, accepted: bool +) -> None: + message = subject + "\n\nHistorical migration described in the PR." + if accepted: + policies.validate_message(message, commit_sha=commit_sha) + else: + with pytest.raises(RuntimeError, match="BREAKING CHANGE footer"): + policies.validate_message(message, commit_sha=commit_sha) + + +def test_actual_main_history_preserves_both_historical_breaking_integrations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Capture actual committed messages/parents so shallow CI checkouts exercise + # the complete regression without network access or moving origin/main. + snapshot = json.loads( + (Path(__file__).parent / "fixtures" / "release_main_history.json").read_text() + ) + commits = snapshot["commits"] + records = {record["sha"]: record for record in commits} + + def read_history(*arguments: str) -> str: + if arguments == ("rev-list", "--first-parent", snapshot["target"]): + return "\n".join( + [record["sha"] for record in reversed(commits)] + [snapshot["published_base"]] + ) + if len(arguments) == 4 and arguments[:3] == ("show", "-s", "--format=%P%n%B"): + record = records[arguments[3]] + return " ".join(record["parents"]) + "\n" + record["message"] + raise AssertionError(f"Unexpected history query: {arguments}") + + monkeypatch.setattr(policies, "git", read_history) + assert policies.PUBLISHED_BASE == snapshot["published_base"] + result = policies.conventional("push", snapshot["target"], {}) + assert len(result["commits"]) == 28 + assert result["commits"] == [record["sha"] for record in commits] + + +def test_pr_policy_uses_live_numeric_author_and_exact_head(history: list[str]) -> None: + api = PolicyAPI(history) + pr = api.data["pulls/1174"] + pr.update(merged=False, merged_at=None, state="open") + event = {"pull_request": copy.deepcopy(pr)} + assert policies.ipr(api, "pull_request", "a" * 40, event)["contributions"][0]["author_id"] == 7 + pr["head"]["sha"] = "f" * 40 + with pytest.raises(ReleaseRejectedError, match="head changed"): + policies.ipr(api, "pull_request", "a" * 40, event) + + +@pytest.mark.parametrize( + "case", + [ + "valid", + "wrong_repo", + "wrong_ref", + "wrong_sha", + "wrong_workflow_sha", + "wrong_event_sha", + "forced", + "deleted", + "dispatch", + ], +) +def test_exact_push_event_is_bound_to_checkout( + history: list[str], monkeypatch: pytest.MonkeyPatch, case: str +) -> None: + event = {"after": history[-1], "ref": "refs/heads/main", "deleted": False, "forced": False} + environment = { + "GITHUB_REPOSITORY": policies.REPOSITORY, + "GITHUB_SHA": history[-1], + "GITHUB_WORKFLOW_SHA": history[-1], + "GITHUB_REF": "refs/heads/main", + "GITHUB_EVENT_NAME": "push", + } + if case in {"forced", "deleted"}: + event[case] = True + elif case == "wrong_event_sha": + event["after"] = history[1] + elif case == "wrong_repo": + environment["GITHUB_REPOSITORY"] = "fork/repository" + elif case == "wrong_ref": + environment["GITHUB_REF"] = "refs/heads/other" + elif case == "wrong_sha": + environment["GITHUB_SHA"] = history[1] + elif case == "wrong_workflow_sha": + environment["GITHUB_WORKFLOW_SHA"] = history[1] + elif case == "dispatch": + environment["GITHUB_EVENT_NAME"] = "workflow_dispatch" + path = policies.ROOT / "event.json" + path.write_text(json.dumps(event)) + for key, value in {**environment, "GITHUB_EVENT_PATH": str(path)}.items(): + monkeypatch.setenv(key, value) + if case == "valid": + assert policies.event_context() == ("push", history[-1], event) + else: + with pytest.raises(ReleaseRejectedError): + policies.event_context() + + +def test_native_policy_jobs_are_read_only_pinned_and_run_on_main() -> None: + root = Path(__file__).resolve().parent.parent + document = yaml.load((root / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader) + for name, command in (("ipr-policy", "ipr"), ("conventional-commits", "conventional")): + job = document["jobs"][name] + assert job["if"] == "github.event_name == 'pull_request' || github.ref == 'refs/heads/main'" + assert all(value == "read" for value in job["permissions"].values()) + checkout = job["steps"][0] + assert checkout["uses"] == "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803" + assert checkout["with"] == { + "ref": "${{ github.sha }}", + "fetch-depth": "0", + "persist-credentials": "false", + } + assert job["steps"][1]["run"] == f"python3 -m scripts.check_main_policies {command}" + assert "secrets." not in json.dumps(job) diff --git a/tests/test_release_guard.py b/tests/test_release_guard.py index c8bcb4c66..102674c32 100644 --- a/tests/test_release_guard.py +++ b/tests/test_release_guard.py @@ -82,6 +82,16 @@ def __init__(self) -> None: self.data: dict[str, Any] = { "": {"id": 123, "full_name": policy.REPOSITORY}, "git/ref/heads/main": {"object": {"sha": TARGET, "type": "commit"}}, + "branches/main": { + "name": "main", + "commit": {"sha": TARGET}, + "protection": { + "required_status_checks": { + "contexts": [check["name"] for check in checks], + "checks": [{"context": check["name"], "app_id": 15368} for check in checks], + } + }, + }, "actions/workflows/204238826": {"state": "disabled_manually"}, "actions/workflows/204238826/runs": [], "rules/branches/main": [ @@ -783,6 +793,46 @@ def test_exact_artifact_acceptance( assert api.writes == [] +@pytest.mark.parametrize("suffix", [".whl", ".tar.gz"]) +@pytest.mark.parametrize("recovery", [False, True]) +def test_same_version_rebuild_cannot_replace_accepted_distribution_bytes( + context: policy.Context, + candidate: tuple[Path, dict[str, Any]], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + suffix: str, + recovery: bool, +) -> None: + directory, manifest = candidate + name = next(name for name in manifest["files"] if name.endswith(suffix)) + path = directory / name + # Another build can have the same filename, version, metadata and source + # inventory. It still needs its own acceptance; a version-only lookup cannot + # substitute its bytes, including when recovering an interrupted release. + if suffix == ".whl": + with zipfile.ZipFile(path, "a") as archive: + archive.comment = b"independent same-version build" + else: + path.write_bytes(gzip.compress(gzip.decompress(path.read_bytes()), mtime=1)) + assert artifacts.inventory(path, VERSION) == manifest["files"][name]["inventory"] + assert artifacts.file_digest(path) != manifest["files"][name]["sha256"] + api = FakeGitHub() + run_id = 899 if recovery else context.run_id + monkeypatch.setenv("ARTIFACT_DIGEST", attach_artifact(api, candidate, run_id=run_id)) + with pytest.raises(REJECTED, match="artifact hash or inventory mismatch"): + if recovery: + api.data["actions/runs/899"] = { + **workflow_run(policy.WORKFLOWS["publish"], "workflow_dispatch"), + "conclusion": "failure", + } + artifacts.recover_candidate( + api, replace(context, recover_from=899), tmp_path / "recovery" + ) + else: + artifacts.verified_candidate(api, context, tmp_path / "downloaded") + assert api.writes == [] + + @pytest.mark.parametrize( "case", [ @@ -1200,6 +1250,103 @@ def forbidden(_: str) -> Any: api.optional("environments/release-publish") +@pytest.mark.parametrize("name", ["IPR Policy / Signature", "Validate conventional commit format"]) +def test_policy_check_must_belong_to_selected_main_ci_attempt( + context: policy.Context, name: str +) -> None: + api = FakeGitHub() + job = next(job for job in api.data["actions/runs/800/attempts/1/jobs"] if job["name"] == name) + job["check_run_url"] = "https://api.github.com/repos/unrelated/run/check-runs/1" + with pytest.raises(REJECTED, match="selected CI attempt"): + policy.gate(api, context) + + +@pytest.mark.parametrize( + ("name", "app_id", "other_name"), + [ + ("check / check", 15368, "check"), + ("CodeQL", 57789, "Analyze (python)"), + ("GitGuardian Security Checks", 46505, "Security scan"), + ], +) +@pytest.mark.parametrize("case", ["valid", "missing", "wrong_name", "wrong_app", "failed", "stale"]) +def test_branch_summary_checks_are_required_in_addition_to_rulesets( + context: policy.Context, name: str, app_id: int, other_name: str, case: str +) -> None: + api = FakeGitHub() + summary = api.data["branches/main"]["protection"]["required_status_checks"] + summary["contexts"].append(name) + summary["checks"].append({"context": name, "app_id": app_id}) + check = { + "id": 700, + "name": other_name if case == "wrong_name" else name, + "head_sha": TARGET, + "app": {"id": (15368 if app_id != 15368 else 1) if case == "wrong_app" else app_id}, + "status": "completed", + "conclusion": "failure" if case == "failed" else "success", + "completed_at": (NOW - timedelta(days=2)).isoformat() if case == "stale" else STAMP, + } + if case != "missing": + api.data[f"commits/{TARGET}/check-runs?filter=latest"].append(check) + if case == "valid": + policy.gate(api, context) + else: + with pytest.raises(REJECTED): + policy.gate(api, context) + assert api.writes == [] + + +@pytest.mark.parametrize( + "case", + [ + "missing_contexts", + "missing_checks", + "unbound_context", + "missing_app", + "wrong_target", + "wrong_branch", + ], +) +def test_incomplete_branch_summary_cannot_be_treated_as_no_classic_protection( + context: policy.Context, case: str +) -> None: + api = FakeGitHub() + branch = api.data["branches/main"] + summary = branch["protection"]["required_status_checks"] + if case == "missing_contexts": + summary.pop("contexts") + elif case == "missing_checks": + summary.pop("checks") + elif case == "unbound_context": + summary["contexts"].append("CodeQL") + elif case == "missing_app": + summary["checks"][0].pop("app_id") + elif case == "wrong_target": + branch["commit"]["sha"] = OTHER + else: + branch["name"] = "another-branch" + with pytest.raises(REJECTED): + policy.gate(api, context) + assert api.writes == [] + + +def test_branch_summary_permission_denial_is_not_an_empty_protection_inventory( + context: policy.Context, monkeypatch: pytest.MonkeyPatch +) -> None: + api = FakeGitHub() + read = api.repo + + def denied(suffix: str, method: str = "GET", body: Any = None) -> Any: + if suffix == "branches/main": + raise urllib.error.HTTPError("https://api.github.com", 403, "forbidden", {}, None) + return read(suffix, method, body) + + monkeypatch.setattr(api, "repo", denied) + with pytest.raises(urllib.error.HTTPError): + policy.gate(api, context) + assert api.writes == [] + + def workflow(name: str) -> dict[str, Any]: # BaseLoader preserves the YAML 1.2 Actions key `on`, unlike PyYAML's # YAML 1.1 boolean resolver. String booleans are intentional below.