diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..965a9c32b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.github/workflows/*.lock.yml linguist-generated=true diff --git a/.github/MONTHLY_PATCH.md b/.github/MONTHLY_PATCH.md new file mode 100644 index 000000000..bae70f9f5 --- /dev/null +++ b/.github/MONTHLY_PATCH.md @@ -0,0 +1,274 @@ +# Monthly Notation patch releases + +The canonical release controller lives in `notaryproject/notation`. It assesses +Core, Go, and CLI together, reserves independent stable patch versions, and +dispatches workers in dependency order. This draft is disabled by default. +The [Proposal: automated monthly Notation patch releases](MONTHLY_PATCH_UPSTREAM_PROPOSAL.md) +describes the shared scope and maintainer responsibilities. + +## Authorization and approval + +The read-only assessment agent produces evidence-bound release, skip, or defer +decisions. It cannot authorize a release, merge a PR, change repository settings, +or waive a finding. The controller authenticates the successful default-main +agent run, revalidates its artifact, and checks the maintainer-owned +`.github/monthly-patch-policy.yml`. + +The controller creates one release-scope issue per repository after assessment. +Each issue shows its own versions and Dependabot source revisions. Go links the +Core issue; CLI links Core and Go and shows overall status, without copying +their change lists. Source details and the central technical state are collapsed. +Metadata voting commits remain separate from Dependabot source commits. +Creation starts with CLI, then Core and Go, then wires exact issue links. +An interrupted creation resumes from authenticated placeholders. Duplicate, +missing, or mismatched issue records fail closed before execution. + +An active CLI repo-level maintainer must post the exact, unedited command +displayed in the CLI issue: `/approve`. The controller polls every +six hours or on manual resume. It checks the current `MAINTAINERS` roster, +the issue's automation author/editor and exact rendered body, comment author, +comment identity/body/edit timestamps, policy, source heads, and immutable +worker pins before execution. It re-fetches the same approval on later writes. +Editing/deleting the comment revokes remaining execution; retired maintainers, +closing issues, checklists, unknown actors, and AI verdicts cannot approve. +Reassessment produces a new internal plan ID and review revision requiring a +fresh comment. The receipt binds the comment ID, plan ID, and review revision; +an older `/approve` cannot authorize a later plan. + +The implemented routine policy permits stable, nondecreasing Go dependency +patches on the same major/minor, without changing module identities, dependency +membership, or Go requirements. Workflow dependency changes and broader updates +require a manual controller dispatch by `MONTHLY_PATCH_ACTOR` with +`approve=true` to request exceptional plan review; this input never bypasses +the maintainer comment gate. The policy does not authorize feature merges, source bug fixes, +or CVE backports. Reachable source and binary `govulncheck` findings block +canonical releases at every severity. Advisory dispositions cannot waive them. + +The read-only collector also compares `main` against the selected release line +and supplies source/PR evidence for proposed bug-fix backports. The assessment +must explain applicability, known cherry-pick provenance or reverts, and missing +evidence. Titles or labels alone do not establish a bug fix. Candidates appear +only in their repository's issue, separate from the included Dependabot scope. +Proposal metadata cannot authorize a backport. Including one requires separately +reviewed execution support and fresh scope approval; new `main` commits never +silently join a frozen plan. + +Plan approval does not replace release voting. Go and CLI workers +first open dependency-only backport PRs to the stable release branch. After +ordinary successful CI and required branch reviews, the worker merges the exact +backport with a normal merge commit. The worker then opens a separate signed +single-commit release-voting PR. Go changes only `signer/signer.go`'s +`signingAgent`; CLI changes only `Version` and `BuildMetadata` in +`internal/version/version.go`. + +The voting PR must receive current-head approval from a strict majority of the +active repo-level maintainers in `MAINTAINERS` at the pinned assessed `main`. +Dismissed or stale approvals do not count. Changes-requested reviews block the +vote. The worker requires ordinary CI and branch reviews and uses a normal +merge commit, never an administrative merge. Core has no inferred version edit +or mandatory majority vote; its backport still follows ordinary branch rules. + +Go and CLI qualify and tag the recorded signed voting commit. The release +branch remains at the verified normal merge tip. The worker checks the voted +tree, merge parents, signatures, majority, and immutable receipt again before +tagging and publication. + +## Qualified draft review and manual publication + +The isolated stager pushes the exact signed annotated tag with the existing +branch/tag leases, then creates a real GitHub draft release (`draft=true`, +`prerelease=false`) at that stable tag. The tag is already public. A draft does +not hide the tag or prevent module consumers from observing it. +For upstream use, a manual approval before the final stable tag remains required +but unimplemented. The existing draft-publication gate does not provide that +earlier boundary; this issue-topology change does not redesign publication. + +The stager records the release ID and exact qualified asset names, IDs, sizes, +and SHA-256 hashes. Source, packaging, and native tests receive no release-write +credential. Native runners fetch the recorded draft assets with only target +`GITHUB_TOKEN` Contents read, validate bytes, and run the same package gates +before any qualified-review request. An asset API response may stream bytes +or redirect to signed GitHub storage; credentials are never forwarded to the +storage redirect. Denied read-only access blocks the gate without a write-token +fallback. Maintainers must validate live draft access before activation. +Interrupted uploads retain the reserved release ID and original hashes. +Retries may upload only missing identical bytes; a different rebuild is blocked, +never used to replace existing assets. Orphaned unrecorded drafts require +explicit reconciliation rather than adoption. + +After every draft/native gate passes, the worker comments in the existing +repository release-scope issue. It lists actual +Dependabot source receipts, the backport candidate, signed voted HEAD and merge +tip, release ID, asset IDs/hashes/sizes, and source/scan/native evidence. Failed +drafts stay blocked and never receive a qualified-review request. + +The maintainer inspects that issue and the exact GitHub draft, chooses **Edit** +in Releases, and chooses **Publish release** without changing its tag or assets. +An approval comment or issue close cannot publish it. This implementation never +turns a draft into a public release; future automatic publication is not +implemented and needs separate review and opt-in. The draft author's identity +is not used to infer who later published it. + +On resume, the worker requires the same release ID, stable tag, signed/voted +commit, asset IDs, sizes, and hashes. A deleted/replaced release, changed assets, +or publication before qualification blocks the cycle. It then downloads public +assets anonymously and repeats native package verification. Only those passing +checks mark the producer published and unblock exact-version consumers. + +## Failure notifications + +Each repository's release issue is its human notification thread. When a worker +fails, the controller comments only in that issue and mentions that repository's +active repo-level maintainers. It includes the affected repo, failed stage when +available, workflow-run/attempt link, retained evidence location, and blocked +downstream releases. Go and CLI receive only concise links when blocked by a +dependency, not repeated dependency error details. Required check/review blockers +and qualified draft-ready requests use the same thread. Routine progress does +not send comments. Source tests, scans, packaging, draft checks, and public +verification remain blocking gates. + +Comments are deduplicated by plan, repo, run, run attempt, and stage. Reconciliation +does not repeat an alert, but a newly failed retry gets a new comment. +Notifications do not rewrite the approved scope or count as approval. +Controller validation/execution failures also post to the existing release +issue. Notification errors fail visibly; they are not treated as delivery. + +Maintainers should subscribe to the release issue and configure GitHub +notification/email preferences. Comments and mentions do not guarantee email +delivery, particularly when the automation uses the recipient's own account. +The issue and linked Actions run remain the audit trail. + +If no trusted cycle issue exists yet, or checkout/authentication prevents the +notifier from running, GitHub's failed-workflow notification and logs are the +fallback. Bootstrap failures before trusted worker setup are reported when the +controller next reconciles. Neither notification nor retry bypasses a failed +gate or publishes a draft. + +## Repository setup + +The CLI owns `.github/workflows/monthly-patch-worker.yml`, all release scripts +and tests, the validation workflow, agent, compiled lock, controller, policy, +and packaging configuration. Each repository owns only its dispatch caller +`.github/workflows/monthly-patch-release.yml` and repository-specific setup +documentation. Core and Go do not copy release scripts or tests. +No product dependency or toolchain upgrade is required by this installation. + +The CLI caller uses `./.github/workflows/monthly-patch-worker.yml` and passes +`worker_sha: ${{ github.sha }}`, selecting the same commit without a circular +self-SHA literal. Each library caller must use +`notaryproject/notation/.github/workflows/monthly-patch-worker.yml@FULL40SHA` +and pass that identical literal SHA as `worker_sha`. Moving tags, `main`, +different helper pins, arbitrary repositories, and missing pins fail closed. +There is no fallback to the latest shared code. + +Maintainers first commit the central implementation and read its exact commit +SHA, then create separate library caller commits with that SHA. A draft can +contain this provisional, unmerged SHA, but library PRs must not merge until the +central CLI PR merges. Maintainers must replace provisional pins with the final +upstream merged implementation SHA before merging either library PR or activating +the system. The controller requires the revision to be reachable from canonical +CLI default-main history and requires unchanged assessed shared automation. +Resolving an unmerged commit through GitHub's fork network is not authorization. + +| Location | Required configuration | +|---|---| +| All repos, **Settings > General** | Enable issues for controller and worker cycle records. Enable **Allow merge commits** for backport and voting PRs. | +| All repos, **Settings > Actions > General > Actions permissions** | Permit the pinned Actions and the public reusable workflow from `notaryproject/notation`. If an allow-list is configured, allow that repository's reusable workflow at the reviewed full SHA. Caller permissions include Contents, Actions, Issues, and Pull requests read; the called worker cannot elevate them. | +| All repos, **Settings > Rules > Rulesets**, or **Settings > Branches** | Protect `main` and the latest stable `release-MAJOR.MINOR` branch with the normal required checks and reviews. Ensure normal CI also targets stable-branch backport PRs. Missing checks block execution; do not give the release actor bypass rights. | +| All repos, **Settings > Advanced Security** (or the repository's security settings) | Enable Dependabot alerts and security updates. Review `.github/dependabot.yml` for Go modules and GitHub Actions on `main`; CLI includes root, E2E, and plugin modules. | +| All repos, **Settings > Secrets and variables > Actions > Secrets** | Add the same `MONTHLY_PATCH_TOKEN`, a dedicated authenticated-user token scoped to the three repositories with Contents, Pull requests, Issues, and Actions read/write. Explicitly approved workflow updates also need Workflows write permission. The token must resolve to `MONTHLY_PATCH_ACTOR` through `GET /user`; GitHub App installation tokens are not supported by this draft's credential check. | +| All repos, **Settings > Secrets and variables > Actions > Secrets** | Add `MONTHLY_PATCH_SIGNING_KEY`, a dedicated unencrypted SSH private signing key. Register its public key under the signer account's **Settings > SSH and GPG keys > New SSH key > Signing key**. | +| All repos, **Settings > Secrets and variables > Actions > Variables** | Set `MONTHLY_PATCH_ACTOR`, `MONTHLY_PATCH_SIGNER_LOGIN`, `MONTHLY_PATCH_SIGNER_EMAIL`, and optionally `MONTHLY_PATCH_SIGNER_NAME`. Use the same release actor in every repo. | +| CLI only, **Settings > Secrets and variables > Actions > Secrets** | Add separate `COPILOT_GITHUB_TOKEN` with **Copilot Requests: Read** and access to the Copilot engine. The agent receives no release token or signing key. | +| CLI only, reviewed PRs on `main` and the stable release branch | Install the `MONTHLY_PATCH_ACTOR` exclusion in `release-github.yml` on both branches. The worker inspects the release branch guard before preparing a publication, preventing a second tag-triggered publisher. | + +The stable baseline must be a verified signed annotated tag. The release branch +must already exist; the worker does not create or repair stable release lines. + +## Activation and recovery + +Every switch is false when unset. Maintainers can manually dispatch the agent +on `main` for read-only assessment, then dispatch the controller with +`assessment_run=,mode=dry-run` to inspect the validated plan. +Dry runs receive no release secrets and create no cycle issues or release PRs. + +Only after setup and policy approval, maintainers set +`MONTHLY_PATCH_ENABLED=true` in all three repositories. In CLI they separately +set `NOTATION_RELEASE_AGENT_ENABLED=true`, +and `NOTATION_RELEASE_COORDINATOR_ENABLED=true`. The first switch enables the +monthly assessment at `0 9 1 * *`; the coordinator resumes at `0 */6 * * *`. +The legacy `NOTATION_RELEASE_AUTO_APPROVAL_ENABLED` setting is obsolete and has +no approval authority, even when true. Every new/replacement plan needs the +maintainer issue comment. Clearing `MONTHLY_PATCH_ENABLED` +stops writing worker stages. + +The controller records exact PR heads, stable baselines, source checkpoints, +caller automation commits/tree fingerprints, the central worker repository, +path, commit/tree fingerprint, workflow identities, and dispatch attempts. +GitHub's reusable context identifies the caller repository, ref, and SHA. The +worker checks the actual run's `referenced_workflows` entry against the approved +central path and SHA before checking out any helper or using release credentials. +Every automation checkout explicitly selects `notaryproject/notation` at that +validated revision; target source and candidate checkouts remain separate. +Progress callbacks and producer completion revalidate the same issued receipt +and actual called revision. A worker-input SHA alone cannot authorize execution. +On canonical `main`, only recorded worker dependency merges can advance the +source checkpoint without invalidating the assessment. Changed automation, +governance, policy, or unrecorded source changes require reassessment. A changed +maintainer roster requires a new release vote, not reuse of retired members' +approvals. Consumers wait for the exact planned, signed, publicly verified producer version and a +qualifying Dependabot update; absence is recorded as waiting, not success. + +For a failed attempt, the configured actor manually dispatches the controller +with `retry_failed=true`. A changed assessment requires +`assessment_run=,approve=true,replan=true`; replanning retains reserved +versions and refuses in-flight workers or replacement public candidates. +Verification-only recovery additionally requires +`recover_public_verification=true` and preserves the original tag, candidate, +release ID, asset IDs, and hashes. It can reverify an unchanged draft or public +candidate, never rebuild, retag, or republish it. The replacement plan still +requires a fresh maintainer comment. Ambiguous dispatches, conflicting PR branches, or duplicate +records require explicit reconciliation and are never blindly repeated. + +Source tests, packaging, the agent, and downloaded-package tests run without +release secrets. A separate trusted-default-main stager signs and creates drafts +only after qualification. It never moves tags or publishes official releases. + +The read-only format preview uses the same scope renderer without enabling fork +execution or creating an approval record: + +```sh +python3 .github/scripts/notation_release_controller.py preview \ + --output /tmp/notation-plan-preview +``` + +It reads live canonical Dependabot metadata, not qualification results. An +optional `--inventory ` uses a previously captured +read-only preview inventory. A fork issue containing the resulting +`plan-preview.md` cannot authorize canonical execution. + +## Local validation + +The central regression suite covers canonical authorization, signing, source +and public-package gates, release votes, caller pins, and reusable-run provenance: + +```sh +python3 -m unittest discover \ + -s .github/scripts -p 'test_*release*.py' +python3 -m unittest discover \ + -s .github/scripts -p 'test_upstream_authorization.py' +``` + +The validation workflow also runs pinned `actionlint v1.7.12`. Compile the +assessment lock with existing pinned `gh-aw v0.89.21`; do not edit it by hand: + +```sh +gh aw compile notation-release-agent --strict \ + --gh-aw-ref c35393777e5604a63721d09512263b1383301d4f --no-check-update +``` + +Local fixture tests do not prove repository credential permissions, live +Dependabot propagation, production release votes, or platform-specific public +package checks. Maintainers must validate those settings and gates before +production activation. This draft has not dispatched or published an upstream +release. diff --git a/.github/MONTHLY_PATCH_UPSTREAM_PROPOSAL.md b/.github/MONTHLY_PATCH_UPSTREAM_PROPOSAL.md new file mode 100644 index 000000000..293dffcc2 --- /dev/null +++ b/.github/MONTHLY_PATCH_UPSTREAM_PROPOSAL.md @@ -0,0 +1,148 @@ +# Proposal: automated monthly Notation patch releases + +We propose monthly patch releases across **`notation-core-go`, `notation-go`, +and `notation` (CLI)**, automated under a maintainer-approved policy. + +This draft requires maintainer approval of each plan and maintainer publication +of each qualified draft release. Automatic approval and publication are not implemented. +All schedules and publishing remain disabled until maintainers configure and +explicitly activate them. Local tests do not establish production readiness. + +## 1. Release scope + +Each patch targets the repo's **latest stable release line**, with applicable +dependency changes backported from `main`. Versions remain independent. + +The following scope applies to all three repos: + +| Change | Monthly patch scope | +|---|---| +| Dependabot PRs | Included | +| Additional CVE fixes | Optional, outside automatic authority in this draft | +| Bug-fix backports | Proposed after read-only review of new `main` commits; inclusion remains optional and outside automatic execution | +| Features and breaking changes | Excluded | +| Older release lines | Excluded | + +The following table covers only cross-repository prerequisites. +Each repo must also satisfy its own release checks. + +| Repository | Cross-repository release prerequisite | +|---|---| +| Core | None among these three repos | +| Go | Wait for Core if Core is releasing in the same cycle | +| CLI | Wait for Core and Go if they are releasing in the same cycle | + +Each repo can release its own dependency updates independently. Repos without updates or a planned library version to consume are skipped. + +The implemented routine policy permits nondecreasing Go dependency patches on +the same stable major/minor, with unchanged Go and module requirements. +GitHub Actions updates, broader dependency upgrades, and replans require an +explicit manual dispatch by the configured release actor. Required producer +updates must consume the exact planned canonical versions. + +## 2. Release solution + +One monthly trigger starts a shared release plan. + +| Component | Uses AI? | Responsibility | +|---|---|---| +| Assessment agent | Yes | Assesses all three repos and proposes release, skip, or defer | +| Release controller | No | Opens the plan review issue, verifies maintainer approval, enforces order, and tracks progress | +| Release workers | No | Merge reviewed updates, run checks, sign tags, stage draft assets, and verify publication | + +1. The agent assesses Dependabot PRs, stable baselines, manifests, checks, and + evidence-backed bug-fix candidates from `main` against the selected release line. +2. The controller opens one release-scope issue per repository. Each shows its + own baseline, proposed version, and exact Dependabot revisions. Go links Core; + CLI links Core and Go and shows overall status, without repeating their change + lists. An active CLI repo-level maintainer posts `/approve` on the CLI issue. + The controller binds it to the current immutable plan and rejects stale approvals. +3. Workers prepare producers first. Consumers adopt the exact verified library + versions through checked Dependabot PRs before releasing. Workers open + dependency-only backport PRs to the stable release branch, then separate + release-voting PRs where the repository checklist requires them. +4. The worker stages a true draft release and verifies its downloaded assets + using read-only access. Only successful checks add a draft-ready comment to + the same repository's release issue with exact release ID, commits, asset IDs/hashes, and + evidence. A maintainer inspects it and uses GitHub's **Publish release**. + The worker then verifies the same release and bytes anonymously before + consumers proceed or the cycle closes. + +| Gate | Maintainer action | What remains blocked | +|---|---|---| +| Shared plan issue | Post the exact approval command displayed in the issue | All worker execution until approval | +| Qualified draft-ready comment in the repository's release issue | Inspect evidence and publish the exact draft through GitHub Releases | GitHub release publication and consumers until manual publication and public verification | + +An issue close, checklist, AI verdict, or unknown/comment-edited approval does +not pass either gate. Replans require a fresh `/approve` for the new internal +plan revision. Tags are signed and public before draft review, which already +exposes Go modules. A separate manual gate before the final stable tag is +required for upstream use and is not implemented by this issue-organization change. + +Errors, blockers, and draft-ready notices go only to the affected repository's +release issue and mention its maintainers. Consumers receive a concise dependency +issue link, not repeated error details. Routine progress does not send comments. +Repeated reconciliation does not duplicate a stage/run notification. + +Producer failures block consumers. Callbacks and reconciliation resume the +same cycle without duplicate releases. Later updates enter a new cycle. + +## 3. Upstream configuration and maintainer approval + +The central agent and controller belong in **`notaryproject/notation`**. + +| Repository | Changes required | +|---|---| +| `notation-core-go` | Thin dispatch caller pinned to the central worker's full commit SHA and local setup documentation | +| `notation-go` | Thin pinned caller and local setup documentation; central gates verify adoption of the planned Core version | +| `notation` | Shared reusable worker, all helpers/tests, agent, controller, policy, packaging, and downloaded-CLI tests | + +The following setup maps to the code in this draft. The central implementation +must merge into canonical CLI `main` before maintainers finalize and merge the +library callers. CLI uses a relative reusable-workflow call at the same commit. +Core and Go use the same full 40-character SHA in `uses` and `worker_sha`; they +do not copy helpers. Provisional draft pins must be replaced with the final +upstream implementation SHA before library merge or activation. The controller +checks canonical default-main ancestry, caller/tree identity, and the actual +run's called workflow path/SHA. A resolvable unmerged fork-network commit does +not qualify, and there is no fallback to `main`. + +| Step | Where | Configuration | Maintainer approval | +|---|---|---|---| +| 1. Merge automation | CLI first, then libraries | Merge the central worker/helpers into CLI `main`, initially disabled. Replace library draft pins with its final upstream SHA, then merge only the thin callers/local setup docs. Use canonical module requirements, not trial `replace` directives. In CLI, guard `release-github.yml` on `main` and the selected release branch so it does not publish monthly-worker tags. | Required for the PRs, final pin review, and release-branch changes | +| 2. Configure repositories | All three repos | Enable Actions in **Settings > Actions > General**, allow the public `notaryproject/notation` reusable workflow at its reviewed SHA under **Actions permissions**, and enable issues in **Settings > General**. Maintain the latest stable `release-MAJOR.MINOR` branch and its required checks/reviews. Ensure normal CI runs on stable-branch backport PRs, not just `main`. Configure `.github/dependabot.yml` for Go modules and GitHub Actions on `main`; include CLI's root, E2E, and plugin modules. Enable Dependabot alerts and security updates in repository security settings. | Required for settings and configuration PRs | +| 3. Provision release credentials | All three repos | In **Settings > Secrets and variables > Actions > Secrets**, add the same `MONTHLY_PATCH_TOKEN`, an authenticated user token for one common release actor. Scope it to these three repos with contents, PRs, issues, and Actions write permissions; include workflow-write permission for explicitly approved workflow dependency updates. The controller verifies the token with `GET /user`; installation-token rotation is not implemented. Do not grant protection bypass. | Required for credential provisioning | +| 4. Configure signing and identity | All three repos | Add secret `MONTHLY_PATCH_SIGNING_KEY`. Register its public key as a GitHub signing key on the signer account. In the **Variables** tab, set `MONTHLY_PATCH_ACTOR`, `MONTHLY_PATCH_SIGNER_LOGIN`, and `MONTHLY_PATCH_SIGNER_EMAIL`; optionally set `MONTHLY_PATCH_SIGNER_NAME`. Use the same actor across all three repos. | Required for signer and actor setup | +| 5. Configure AI access | CLI repo only | Add Actions secret `COPILOT_GITHUB_TOKEN` with **Copilot Requests: Read** permission and access to the selected Copilot engine. Keep it separate from release credentials. | Required for account access and secret provisioning | +| 6. Review the manual-first policy | CLI repo | Review `.github/monthly-patch-policy.yml`, dependency compatibility, blocking reachable-vulnerability gates, both manual gates, and repository release votes. The legacy `NOTATION_RELEASE_AUTO_APPROVAL_ENABLED` variable is obsolete and cannot bypass plan approval, even if set. A changed or unsupported policy fails closed. | Required for the policy and implementation PR | +| 7. Activate staging and review | All three repos, plus CLI settings | After approving setup, set Actions variable `MONTHLY_PATCH_ENABLED=true` in all three repos. In CLI, set `NOTATION_RELEASE_AGENT_ENABLED=true` and `NOTATION_RELEASE_COORDINATOR_ENABLED=true`. The agent schedule is `0 9 1 * *`; reconciliation is `0 */6 * * *` and can also be dispatched manually. Maintainers still approve each plan and publish each qualified draft. | Required for production activation | + +During operation: + +| Step | Workflow action | Maintainer approval | +|---|---|---| +| Assess and review a cycle | Validate the shared plan and wire each repository's own release issue | An active CLI repo-level maintainer must post an unedited `/approve` after the current plan revision before execution | +| Merge dependency or producer-update PRs | Require successful checks and satisfied branch rules | Required wherever normal PR reviews are required | +| Merge release backport PRs | Require successful checks, satisfied branch rules, and exact candidate scope | Required wherever normal PR reviews are required | +| Vote on Go and CLI releases | Update only `signingAgent` or CLI version metadata in a separate, signed, single-commit PR, require current-head approval from a strict majority of the repo-level maintainers pinned at assessed `main`, and use a normal merge commit | Required by each repository's existing release checklist | +| Propose bug-fix backports | Review `main` ancestry, associated PR/diff evidence, release-line inclusion, cherry-pick provenance, reverts, and applicability; list candidates only in their own repository's issue | Proposals do not enter execution. Inclusion requires separate reviewed execution support and fresh scope approval; features remain excluded | +| Include optional CVE fixes | Keep these changes outside this draft's automatic authority | Separate reviewed implementation and scope approval are required | +| Handle a conflict, failed check, or blocking security finding | Stop the affected chain and record the blocker | Maintainer resolution and, where refs changed, explicit actor-approved reassessment before resuming; canonical advisory exceptions are not implemented | +| Review qualified draft releases | Record successful draft/native checks and comment in the existing repository release issue | A maintainer inspects assets/evidence and uses GitHub Publish release; automation never publishes | +| Verify public patches | Compare the recorded release ID, tag, asset IDs, hashes, and sizes, then run anonymous public-package checks | Consumers stay blocked until checks pass | + +The agent is read-only. Source tests and the agent receive no release secrets. +Required reviews and release gates are not bypassed. Advisory exceptions are +not implemented. + +Core has no repository-local release-voting requirement in the inspected +baseline. Its dependency backport PR still requires the repository's ordinary +branch checks and reviews. Go and CLI tag the signed voting PR commit recorded +by their checklists; the release branch remains at the verified normal merge +tip. Qualification tests and public-package metadata identify that exact voted +commit. + +Read-only draft asset access must succeed in the target repository with +`GITHUB_TOKEN` Contents read. Denied access blocks qualification; workers never +pass the release-write token to source or native tests. Future automatic +publication requires separate reviewed implementation and maintainer opt-in. diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json new file mode 100644 index 000000000..9c56acc3c --- /dev/null +++ b/.github/aw/actions-lock.json @@ -0,0 +1,9 @@ +{ + "entries": { + "github/gh-aw/actions/setup@c35393777e5604a63721d09512263b1383301d4f": { + "repo": "github/gh-aw/actions/setup", + "version": "c35393777e5604a63721d09512263b1383301d4f", + "sha": "c35393777e5604a63721d09512263b1383301d4f" + } + } +} diff --git a/.github/monthly-goreleaser.yml b/.github/monthly-goreleaser.yml new file mode 100644 index 000000000..b2195bc37 --- /dev/null +++ b/.github/monthly-goreleaser.yml @@ -0,0 +1,42 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version: 2 +project_name: notation +builds: + - main: ./cmd/notation + id: notation + binary: notation + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm64, arm] + goarm: ['7'] + ignore: + - goos: windows + goarch: arm64 + - goos: windows + goarch: arm + - goos: darwin + goarch: arm + ldflags: + - -w -X {{.ModulePath}}/internal/version.Version={{.Version}} -X {{.ModulePath}}/internal/version.GitCommit={{.FullCommit}} -X {{.ModulePath}}/internal/version.BuildMetadata= +archives: + - formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: [LICENSE] +release: + draft: true +changelog: + disable: true diff --git a/.github/monthly-patch-policy.yml b/.github/monthly-patch-policy.yml new file mode 100644 index 000000000..45356f203 --- /dev/null +++ b/.github/monthly-patch-policy.yml @@ -0,0 +1,22 @@ +{ + "schema": 1, + "repositories": [ + "notaryproject/notation-core-go", + "notaryproject/notation-go", + "notaryproject/notation" + ], + "dependency_versions": "same-major-minor-nondecreasing", + "producer_versions": "exact-planned-stable-patch", + "go_requirement": "unchanged-for-routine-updates", + "reachable_vulnerabilities": "block", + "optional_backports": "excluded", + "workflow_updates": "explicit-approval", + "maximum_assessment_age_hours": 24, + "plan_approval": "active-cli-maintainer-comment", + "publication": "maintainer-publishes-qualified-draft", + "release_votes": { + "notation-core-go": "branch-rules", + "notation-go": "repo-maintainer-majority", + "notation": "repo-maintainer-majority" + } +} diff --git a/.github/scripts/build_monthly_assets.py b/.github/scripts/build_monthly_assets.py new file mode 100644 index 000000000..819a1e7b2 --- /dev/null +++ b/.github/scripts/build_monthly_assets.py @@ -0,0 +1,66 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Build library source assets or gate all six GoReleaser CLI archives.""" + +import argparse +import gzip +import hashlib +import pathlib +import tempfile + +from monthly_release import git +from monthly_release_checks import ( + PLATFORMS, asset_names, checked, extract_binary, library_source_archive, scan, + verify_checksums, verify_metadata, +) + + +def build(source, repository, tag, commit, evidence): + source, evidence = source.resolve(), evidence.resolve() + evidence.mkdir(parents=True, exist_ok=True) + if git(["rev-parse", "HEAD"], source) != commit or git(["rev-parse", f"{tag}^{{commit}}"], source) != commit: + raise ValueError("Assets must be built from the exact qualified, tagged commit") + project = repository.split("/")[1] + names = asset_names(project, tag) + dist = source / "dist" + dist.mkdir(exist_ok=True) + if project != "notation": + archive = library_source_archive(source, project, tag, commit) + (dist / names[0]).write_bytes(gzip.compress(archive, mtime=0)) + (dist / names[1]).write_text(f"{hashlib.sha256((dist / names[0]).read_bytes()).hexdigest()} {names[0]}\n") + archives = verify_checksums(dist, project, tag) + if project == "notation": + failures = [] + for platform, name in zip(PLATFORMS, archives): + with tempfile.TemporaryDirectory(prefix="monthly-binary-") as temporary: + binary = extract_binary(dist / name, pathlib.Path(temporary) / ("notation.exe" if platform.startswith("windows") else "notation")) + try: + metadata = checked(["go", "version", "-m", binary], evidence / f"{platform}-metadata.txt") + verify_metadata(metadata, tag, commit, platform) + scan(["govulncheck", "-mode=binary", "-format=json", str(binary)], evidence / platform, "binary") + except ValueError as error: + failures.append(f"{platform}: {error}") + if failures: + raise ValueError("\n".join(failures)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=pathlib.Path) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--evidence", type=pathlib.Path, required=True) + args = parser.parse_args() + build(args.source, args.repository, args.tag, args.commit, args.evidence) diff --git a/.github/scripts/monthly_cli_smoke.py b/.github/scripts/monthly_cli_smoke.py new file mode 100644 index 000000000..90d873f55 --- /dev/null +++ b/.github/scripts/monthly_cli_smoke.py @@ -0,0 +1,115 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Native functional checks of an existing CLI, isolated from user configuration.""" + +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import tempfile + + +def smoke(binary, source, tag, commit, evidence): + binary, source, evidence = binary.resolve(), source.resolve(), evidence.resolve() + evidence.mkdir(parents=True, exist_ok=True) + transcript, results = [], [] + with tempfile.TemporaryDirectory(prefix="monthly-cli-smoke-") as temporary: + root = pathlib.Path(temporary) + home = root / "home" + home.mkdir() + environment = { + **os.environ, "HOME": str(home), "USERPROFILE": str(home), + "APPDATA": str(home / "appdata"), "LOCALAPPDATA": str(home / "localappdata"), + "XDG_CONFIG_HOME": str(home / "config"), "XDG_CACHE_HOME": str(home / "cache"), + "NOTATION_CONFIG": str(home / "config/notation"), + "NOTATION_LIBEXEC": str(home / "config/notation"), + "NOTATION_CACHE": str(home / "cache/notation"), "NOTATION_EXPERIMENTAL": "1", + } + + def invoke(*command, success=True, contains=None): + result = subprocess.run( + [str(binary), *command], cwd=root, env=environment, + capture_output=True, text=True, encoding="utf-8", + ) + output = result.stdout + result.stderr + transcript.append("$ notation " + " ".join(command) + "\n" + output) + (evidence / "functional.log").write_text("\n".join(transcript), encoding="utf-8") + if success != (result.returncode == 0): + raise ValueError(f"Unexpected exit {result.returncode}: {command}\n{output}") + if contains and contains not in output: + raise ValueError(f"Missing expected output {contains!r}: {command}\n{output}") + results.append({"command": list(command), "exit_code": result.returncode}) + return output + + version = invoke("version") + for label, value in (("Version", tag.removeprefix("v")), ("Git commit", commit)): + values = [line.split(":", 1)[1].strip() for line in version.splitlines() if line.startswith(label + ":")] + if values != [value]: + raise ValueError(f"Downloaded executable has incorrect {label}") + invoke("--help", contains="verify") + invoke("cert", "generate-test", "--default", "monthly-package-smoke", contains="mark as default signing key") + invoke("key", "ls", contains="monthly-package-smoke") + invoke("cert", "ls", contains="monthly-package-smoke") + policy = { + "version": "1.0", "trustPolicies": [{ + "name": "downloaded-package-smoke", "registryScopes": ["local/e2e"], + "signatureVerification": {"level": "strict"}, + "trustStores": ["ca:monthly-package-smoke"], + "trustedIdentities": ["x509.subject:C=US,ST=WA,O=Notary,CN=monthly-package-smoke"], + }], + } + policy_path = root / "trustpolicy.json" + policy_path.write_text(json.dumps(policy), encoding="utf-8") + invoke("policy", "import", str(policy_path)) + invoke("policy", "show", contains="downloaded-package-smoke") + fixture = source / "test/e2e/testdata/registry/oci_layout/e2e" + manifest, = json.loads((fixture / "index.json").read_text(encoding="utf-8"))["manifests"] + references = [] + for signature_format in ("jws", "cose"): + layout = root / signature_format + shutil.copytree(fixture, layout) + reference = f"{layout}@{manifest['digest']}" if signature_format == "jws" else f"{layout}:{manifest['annotations']['org.opencontainers.image.ref.name']}" + references.append(reference) + invoke("list", "--oci-layout", reference, contains="has no associated signature") + invoke("verify", "--oci-layout", "--scope", "local/e2e", reference, + success=False, contains="no signature") + invoke("sign", "--oci-layout", "--signature-format", signature_format, reference, contains="Successfully signed") + invoke("list", "--oci-layout", reference, contains="application/vnd.cncf.notary.signature") + invoke("verify", "--oci-layout", "--scope", "local/e2e", reference, contains="Successfully verified signature") + policy["trustPolicies"][0]["trustedIdentities"] = ["x509.subject:C=US,ST=WA,O=Notary,CN=untrusted-package-smoke"] + policy_path.write_text(json.dumps(policy), encoding="utf-8") + invoke("policy", "import", "--force", str(policy_path)) + for reference in references: + invoke("verify", "--debug", "--oci-layout", "--scope", "local/e2e", reference, + success=False, contains="does not match the X.509 trusted identities") + (evidence / "functional-summary.json").write_text(json.dumps({ + "tag": tag, "commit": commit, "commands": results, + "jws": "passed", "cose": "passed", "unsigned_rejected": True, + "untrusted_identity_rejected": True, "isolated_configuration": True, + "temporary_keys_removed": True, + }, indent=2) + "\n", encoding="utf-8") + print(f"Downloaded CLI functional checks passed: {len(results)} commands") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", type=pathlib.Path) + parser.add_argument("source", type=pathlib.Path) + parser.add_argument("--tag", required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--evidence", type=pathlib.Path, required=True) + args = parser.parse_args() + smoke(args.binary, args.source, args.tag, args.commit, args.evidence) diff --git a/.github/scripts/monthly_release.py b/.github/scripts/monthly_release.py new file mode 100644 index 000000000..5d0ad3a23 --- /dev/null +++ b/.github/scripts/monthly_release.py @@ -0,0 +1,1318 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Coordinate one dependency-only patch cycle using gh and git.""" + +import argparse +import base64 +import copy +import datetime +import hashlib +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile + +from monthly_release_checks import asset_names + +PROJECTS = { + "notation-core-go": (), + "notation-go": ("notation-core-go",), + "notation": ("notation-core-go", "notation-go"), +} +STABLE = re.compile(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +SHA = re.compile(r"[0-9a-f]{40}$") +MONTH = re.compile(r"\d{4}-(0[1-9]|1[0-2])$") +CYCLE = re.compile(r"monthly|upstream-[0-9a-f]{64}$") +REPOSITORY = re.compile(r"notaryproject/(notation-core-go|notation-go|notation)$") +FINAL = {"published", "skipped"} + + +def run(command, cwd=None, env=None): + result = subprocess.run( + command, cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8" + ) + if result.returncode: + raise RuntimeError( + f"Command failed ({result.returncode}): {' '.join(command)}\n" + f"{result.stdout}{result.stderr}" + ) + return result.stdout + + +class APIError(RuntimeError): + def __init__(self, message): + super().__init__(message) + match = re.search(r"HTTP (\d{3})", message) + self.status = int(match[1]) if match else None + + +class GitHub: + def request(self, path, method="GET", payload=None): + command = ["gh", "api", path, "--method", method] + if payload is not None: + command.extend(["--input", "-"]) + result = subprocess.run( + command, input=json.dumps(payload) if payload is not None else None, + capture_output=True, text=True, encoding="utf-8", + ) + if result.returncode: + raise APIError(f"GitHub request failed: {method} {path}\n{result.stderr}") + return json.loads(result.stdout) if result.stdout.strip() else None + + def optional(self, path): + try: + return self.request(path) + except APIError as error: + if error.status != 404: + raise + return None + + def pages(self, path): + for page in range(1, 1001): + separator = "&" if "?" in path else "?" + items = self.request(f"{path}{separator}per_page=100&page={page}") + if not isinstance(items, list): + raise ValueError(f"Invalid paginated response: {path}") + yield from items + if len(items) < 100: + return + raise ValueError("Pagination limit reached; discovery is incomplete") + + def pulls(self, repository, branch, state): + return list(self.pages( + f"repos/{repository}/pulls?state={state}&base={branch}&sort=updated" + )) + + def manifest(self, repository, branch, directory): + path = "go.mod" if directory == "." else f"{directory}/go.mod" + result = self.request(f"repos/{repository}/contents/{path}?ref={branch}") + if result.get("encoding") != "base64": + raise ValueError(f"Missing module manifest: {path}") + with tempfile.TemporaryDirectory(prefix="monthly-manifest-") as temporary: + modfile = pathlib.Path(temporary) / "go.mod" + modfile.write_bytes(base64.b64decode(result["content"], validate=False)) + return json.loads(run( + ["go", "mod", "edit", "-json", f"-modfile={modfile}"], cwd=temporary + )) + + def checks(self, repository, number): + return json.loads(run([ + "gh", "pr", "view", str(number), "--repo", repository, + "--json", "headRefOid,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup", + ])) + + def issue_editor(self, repository, number): + owner, name = repository.split("/") + query = ( + "query($owner:String!,$name:String!,$number:Int!){" + "repository(owner:$owner,name:$name){issue(number:$number){" + "lastEditedAt editor{login}}}}" + ) + result = self.request("graphql", "POST", { + "query": query, "variables": {"owner": owner, "name": name, "number": number}, + }) + if result.get("errors"): + raise ValueError("Cannot authenticate release record editor") + issue = result["data"]["repository"]["issue"] + if issue is None: + raise ValueError("Release record no longer exists") + if issue["lastEditedAt"] is not None: + editor = (issue.get("editor") or {}).get("login") + if not editor: + raise ValueError("Edited release record has no authenticated editor") + return editor + return None + + +def trusted_editor(api, repository, issue): + if repository.startswith("notaryproject/") and os.environ.get("GITHUB_REPOSITORY", "").startswith("notaryproject/"): + editor = api.issue_editor(repository, issue["number"]) + if editor is not None and editor != os.environ.get("MONTHLY_PATCH_ACTOR"): + raise ValueError("Release state was edited outside the configured actor") + + +def semver(tag): + match = STABLE.fullmatch(tag) + return tuple(map(int, match.groups())) if match else None + + +def latest_release(releases): + eligible = [ + item for item in releases + if not item["draft"] and not item["prerelease"] and semver(item["tag_name"]) + ] + if not eligible: + raise ValueError("No published stable release; a reviewed baseline is required") + return max(eligible, key=lambda item: semver(item["tag_name"])) + + +def modules(repository): + return (".", "test/e2e", "test/e2e/plugin") if repository.split("/")[1] == "notation" else (".",) + + +def cycle_marker(month, mode, cycle_id="monthly"): + if not MONTH.fullmatch(month) or mode != "execute" or not CYCLE.fullmatch(cycle_id): + raise ValueError("Invalid cycle identity") + suffix = "" if cycle_id == "monthly" else f":{cycle_id}" + return f"" + + +def plan_marker(plan): + return cycle_marker(plan["month"], plan["mode"], plan.get("cycle_id", "monthly")) + + +def policy(repository, mode, environment, metadata): + if not REPOSITORY.fullmatch(repository) or mode not in {"dry-run", "execute"}: + raise ValueError("Unsupported repository or mode") + if mode == "dry-run": + return + if mode == "execute": + if repository.split("/")[0] != "notaryproject": + raise ValueError("Stable execution is restricted to the canonical repositories") + if environment.get("MONTHLY_PATCH_ENABLED") != "true": + raise ValueError("Monthly production execution is not enabled") + if environment.get("GITHUB_REF") != "refs/heads/main" or metadata["default_branch"] != "main": + raise ValueError("Production orchestration must run from the trusted default main") + for key in ("MONTHLY_PATCH_TOKEN_READY", "MONTHLY_PATCH_SIGNING_KEY", + "MONTHLY_PATCH_SIGNER_EMAIL", "MONTHLY_PATCH_SIGNER_LOGIN", "MONTHLY_PATCH_ACTOR"): + if not environment.get(key) or (key.endswith("READY") and environment[key] != "true"): + raise ValueError(f"Missing execution prerequisite: {key}") + + +def dependency_file(path): + return ( + path in {"go.mod", "go.sum", "test/e2e/go.mod", "test/e2e/go.sum", + "test/e2e/plugin/go.mod", "test/e2e/plugin/go.sum"} + or bool(re.fullmatch(r"\.github/workflows/[^/]+\.ya?ml", path)) + ) + + +def dependabot(pull, branch): + return ( + pull["user"]["login"] == "dependabot[bot]" + and pull["user"].get("type") == "Bot" + and pull["base"]["ref"] == branch + ) + + +def checks_ready(pull, checks): + if pull.get("draft") or checks["headRefOid"] != pull["head"]["sha"]: + return False, "Draft or changed pull-request head" + if checks["mergeable"] != "MERGEABLE" or checks["mergeStateStatus"] != "CLEAN": + return False, "Pull request is not cleanly mergeable under branch protection" + if checks["reviewDecision"] not in ("", None, "APPROVED"): + return False, "Required review is missing" + results = checks["statusCheckRollup"] + if not results: + return False, "No CI evidence; missing checks never qualify a merge" + successes = 0 + for result in results: + if result.get("__typename") == "StatusContext": + if result.get("state") != "SUCCESS": + return False, "A commit status is not successful" + successes += 1 + else: + if result.get("status") != "COMPLETED": + return False, "A check is pending" + if result.get("conclusion") not in {"SUCCESS", "SKIPPED", "NEUTRAL"}: + return False, "A check failed" + successes += result.get("conclusion") == "SUCCESS" + return (True, "") if successes else (False, "No successful checks") + + +def requirement(manifest, module, producer_repository, mode): + if mode not in {"execute", "dry-run"}: + raise ValueError("Unsupported dependency requirement mode") + entries = [item for item in manifest.get("Require") or [] if item["Path"] == module] + if len(entries) != 1: + raise ValueError(f"Missing or duplicate explicit dependency requirement: {module}") + version = entries[0]["Version"] + replacements = [ + item for item in manifest.get("Replace") or [] + if item["Old"]["Path"] == module and item["Old"].get("Version", "") in ("", version) + ] + if mode == "execute": + if manifest.get("Replace"): + raise ValueError("Production release manifests must not contain trial replacements") + return version + return replacements[0]["New"].get("Version", "") if replacements else version + + +def decode_state(body, repository, month, mode, cycle_id="monthly"): + marker = cycle_marker(month, mode, cycle_id) + if marker not in (body or ""): + return None + blocks = re.findall(r"```json\n(.*?)\n```", body, flags=re.DOTALL) + if len(blocks) != 1: + raise ValueError("Malformed or duplicate cycle state") + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate cycle state field") + result[key] = value + return result + state = json.loads(blocks[0], object_pairs_hook=unique) + if state.get("schema") != 1 or state.get("repository") != repository: + raise ValueError("Cycle state belongs to another repository or schema") + if state.get("month") != month or state.get("mode") != mode or state.get("cycle_id", "monthly") != cycle_id: + raise ValueError("Cycle state identity changed") + if state.get("status") not in {"merging", "waiting", "voting", "ready", "tagged", "staging", "draft", + "awaiting-publication", "verifying", "failed", *FINAL}: + raise ValueError("Unknown cycle outcome") + baseline = semver(state.get("baseline", "")) + if baseline is None: + raise ValueError("Cycle has no stable baseline") + major, minor, patch = baseline + expected_branch = f"release-{major}.{minor}" + expected_main = "main" + if state.get("tag") != f"v{major}.{minor}.{patch + 1}": + raise ValueError("Patch tag does not increment the stable baseline") + if state.get("branch") != expected_branch or state.get("main") != expected_main: + raise ValueError("Cycle branches escape the approved execution namespace") + if state["status"] in {"ready", "tagged", "staging", "draft", "awaiting-publication", "verifying", "published"} and not SHA.fullmatch(state.get("commit", "")): + raise ValueError("Cycle has no valid candidate commit") + if state["status"] in {"ready", "tagged", "staging", "draft", "awaiting-publication", "verifying", "published"} and not SHA.fullmatch(state.get("previous_head", "")): + raise ValueError("Cycle has no valid original release-branch head") + return state + + +def validate_plan(plan): + repository, month, mode = (plan[key] for key in ("repository", "month", "mode")) + if not REPOSITORY.fullmatch(repository): + raise ValueError("Invalid plan repository") + body = plan_marker(plan) + "\n```json\n" + json.dumps(plan) + "\n```" + return decode_state(body, repository, month, mode, plan.get("cycle_id", "monthly")) + + +def resume_cycle(api, repository, month, mode): + active = [] + for issue in api.pages(f"repos/{repository}/issues?state=open"): + if "pull_request" in issue: + continue + if issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR"): + continue + for previous, suffix in re.findall(rf"", issue.get("body") or ""): + if previous > month: + raise ValueError("A future monthly cycle requires reconciliation") + cycle_id = suffix or "monthly" + state = decode_state(issue["body"], repository, previous, mode, cycle_id) + if state["status"] not in FINAL: + if issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR"): + raise ValueError("Active cycle has an unexpected author") + active.append((previous, cycle_id)) + if len(active) > 1: + raise ValueError("Multiple active cycles require reconciliation before release") + return active[0] if active else None + + +class Cycle: + def __init__(self, api, repository, month, mode, cycle_id="monthly"): + self.api, self.repository, self.month, self.mode = api, repository, month, mode + self.cycle_id = cycle_id + self.issue = None + self.state = None + matches = [] + for issue in api.pages(f"repos/{repository}/issues?state=all"): + if "pull_request" in issue: + continue + if issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR"): + continue + state = decode_state(issue.get("body"), repository, month, mode, cycle_id) + if state is not None: + trusted_editor(api, repository, issue) + if not os.environ.get("MONTHLY_PATCH_ACTOR"): + raise ValueError("Cycle record was not created by the configured automation identity") + matches.append((issue, state)) + if len(matches) > 1: + raise ValueError("Duplicate cycle records require manual reconciliation") + if matches: + self.issue, self.state = matches[0] + + def save(self, state): + validate_plan(state) + if (state["repository"], state["month"], state["mode"], state.get("cycle_id", "monthly")) != (self.repository, self.month, self.mode, self.cycle_id): + raise ValueError("Cycle write changed its identity") + title = f"Dependency patch: {self.month} ({self.mode}, {self.cycle_id})" + body = ( + f"{plan_marker(state)}\n" + f"# {title}\n\n" + f"Outcome: **{state['status']}**. {state.get('reason', '')}\n\n" + "```json\n" + json.dumps(state, indent=2) + "\n```\n" + ) + payload = {"body": body, "state": "closed" if state["status"] in FINAL else "open"} + if self.issue: + self.issue = self.api.request( + f"repos/{self.repository}/issues/{self.issue['number']}", "PATCH", payload + ) + else: + payload["title"] = title + self.issue = self.api.request(f"repos/{self.repository}/issues", "POST", payload) + if self.issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR"): + raise ValueError("Cycle write used an unexpected automation identity") + trusted_editor(self.api, self.repository, self.issue) + self.state = state + + +def producer_versions(api, repository, mode): + owner, project = repository.split("/") + ready = {} + for producer in PROJECTS[project]: + target = f"notaryproject/{producer}" + releases = list(api.pages(f"repos/{target}/releases")) + candidates = [] + for item in releases: + if item["draft"] or item["prerelease"]: + continue + version = semver(item["tag_name"]) + if version is None: + continue + candidates.append((version, item)) + selected = None + for _, item in sorted(candidates, key=lambda pair: pair[0], reverse=True): + marker = re.search(r"", item.get("body") or "") + if marker: + cycle = Cycle(api, target, marker[1], marker[2], marker[3] or "monthly") + state = cycle.state + if not state or state["status"] != "published" or state["tag"] != item["tag_name"]: + continue + assert_tag(api, state) + selected = item + break + if selected is None: + raise ValueError(f"No usable public stable producer release: {target}") + ready[producer] = {"repository": target, "version": selected["tag_name"]} + return ready + + +def includes_version(actual, expected): + if actual == expected: + return True + current, required = semver(actual), semver(expected) + return current is not None and required is not None and current >= required + + +def producer_lag(api, repository, ref, mode, producers, at_least=False): + manifests = {directory: api.manifest(repository, ref, directory) for directory in modules(repository)} if producers else {} + return producer_lag_from_manifests(repository, manifests, mode, producers, at_least) + + +def producer_lag_from_manifests(repository, manifests, mode, producers, at_least=False): + lag = [] + for directory in modules(repository): + manifest = manifests[directory] if producers else None + for name, producer in producers.items(): + module = f"github.com/notaryproject/{name}" + if directory != "." and not any(item["Path"] == module for item in manifest.get("Require") or []): + continue + actual = requirement(manifest, module, producer["repository"], mode) + matches = includes_version(actual, producer["version"]) if at_least else actual == producer["version"] + if not matches: + lag.append(f"{directory}: wait for Dependabot to update {module} to {producer['version']}") + return lag + + +def upstream_cycle_id(producers): + versions = {name: {key: item[key] for key in ("repository", "version")} for name, item in producers.items()} + digest = hashlib.sha256(json.dumps(versions, sort_keys=True).encode()).hexdigest() + return f"upstream-{digest}" + + +def pending_notification(api, repository, mode): + owner, producer = repository.split("/") + targets = {f"{owner}/{name}" for name, dependencies in PROJECTS.items() if producer in dependencies} + if not targets: + return None + pending = [] + for issue in api.pages(f"repos/{repository}/issues?state=all"): + if "pull_request" in issue or issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR"): + continue + for month, suffix in re.findall(rf"", issue.get("body") or ""): + state = decode_state(issue["body"], repository, month, mode, suffix or "monthly") + if state["status"] == "published" and targets - set(state.get("notified", [])): + pending.append(state) + return min(pending, key=lambda state: (state["month"], state["tag"])) if pending else None + + +def verified_tag(api, repository, tag, commit): + ref = api.request(f"repos/{repository}/git/ref/tags/{tag}") + if ref["object"]["type"] != "tag": + raise ValueError("Expected an immutable, signed annotated release tag") + annotated = api.request(f"repos/{repository}/git/tags/{ref['object']['sha']}") + if annotated["object"]["sha"] != commit or not annotated["verification"]["verified"]: + raise ValueError("Release tag signature or qualified commit does not match") + return ref["object"]["sha"] + + +def check_release_assets(api, plan, draft): + identifier = plan.get("release_id") + if type(identifier) is not int or identifier <= 0: + raise ValueError("Release manifest has no recorded release ID") + release = api.request(f"repos/{plan['repository']}/releases/{identifier}") + if (release.get("id") != identifier or release.get("tag_name") != plan["tag"] + or release.get("draft") is not draft or release.get("prerelease") is not False): + raise ValueError("Release visibility or stable/prerelease identity changed") + if plan_marker(plan) not in (release.get("body") or "") or plan["commit"] not in release["body"]: + raise ValueError("Release does not identify the qualified cycle") + assets = list(api.pages(f"repos/{plan['repository']}/releases/{release['id']}/assets")) + expected = {item["name"]: item for item in plan["assets"]} + if set(expected) != set(asset_names(plan["repository"].split("/")[1], plan["tag"])) or len(expected) != len(plan["assets"]): + raise ValueError("Publication manifest has an invalid asset set") + if not all(re.fullmatch(r"[0-9a-f]{64}", item.get("sha256", "")) and type(item.get("size")) is int and item["size"] > 0 for item in expected.values()): + raise ValueError("Publication manifest has invalid asset hashes or sizes") + if len(assets) != len(expected) or {item["name"] for item in assets} != set(expected): + raise ValueError("Public release assets differ from the qualified manifest") + for item in assets: + match = expected[item["name"]] + if (type(match.get("id")) is not int or match["id"] <= 0 or item.get("id") != match["id"] + or item["size"] != match["size"] or item.get("digest") != f"sha256:{match['sha256']}"): + raise ValueError(f"Public release asset integrity changed: {item['name']}") + return release + + +def check_public_assets(api, plan): + if not plan.get("draft_verification") or not plan.get("draft_review_issue"): + raise ValueError("Public release was not a qualified reviewed draft") + check_draft_review(api, plan) + return check_release_assets(api, plan, False) + + +def draft_review_body(plan): + from notation_release_controller import safe_title + record = {key: plan[key] for key in ("repository", "month", "tag", "commit", "release_id", + "assets", "draft_verification", "controller_issue")} + record["controller_plan_id"] = plan.get("draft_plan_id", plan["controller_plan_id"]) + marker = f"" + scope = ["## Actual release commits", "", + "| Change kind | PR | Exact commit |", "|---|---|---|"] + for item in plan.get("backports", plan["merged"]): + scope.append(f"| Dependabot source | [#{item['number']}](https://github.com/{plan['repository']}/pull/{item['number']}) " + f"{safe_title(item.get('title', 'Dependency update'))} | `{item['commit']}` |") + if item.get("backport_commit"): + scope.append(f"| Signed dependency backport of PR #{item['number']} | Stable candidate source change | `{item['backport_commit']}` |") + backport = plan.get("release_backport", {}) + scope.append(f"| Qualified stable dependency candidate/backport | Ordinary reviewed release PR | `{backport.get('head_sha', plan['commit'])}` |") + label = "Signed release HEAD (Core dependency candidate)" if plan["repository"].endswith("/notation-core-go") else "Signed voted release HEAD (metadata, not Dependabot)" + scope.append(f"| {label} | Repository release governance | `{plan['commit']}` |") + scope.append(f"| Normal stable-branch merge tip | Preserved branch lease | `{plan['previous_head']}` |") + return ( + f"{marker}\n# Qualified draft release review\n\n" + f"Review [{plan['tag']} draft](https://github.com/{plan['repository']}/releases/tag/{plan['tag']}) " + f"(release ID {plan['release_id']}). Source, scan, packaged-asset, and downloaded-draft native " + "checks passed for the exact candidate and recorded bytes below.\n\n" + f"Plan: https://github.com/notaryproject/notation/issues/{plan['controller_issue']}\n\n" + "**Maintainer action:** inspect the draft release, all assets, hashes, and retained evidence. " + "In GitHub Releases, open this exact draft, choose Edit, and choose Publish release " + "without changing its tag, commit, assets, or stable release identity. " + "Do not publish if any check or evidence is missing. Closing this issue or commenting " + "does not publish or authorize the bot to publish. Only a maintainer publishes in this phase. " + "Automation will verify the same release ID and bytes anonymously after publication " + "before completing the cycle or releasing consumers.\n\n" + "The signed annotated tag is already public. A draft does not hide or revoke the tag. " + "Do not move or recreate it. Publication automation is not implemented.\n\n" + + "\n".join(scope) + "\n\n" + + f"```json\n{json.dumps(record, indent=2)}\n```\n" + ) + + +def check_draft_review(api, plan): + number = plan.get("draft_review_issue") + if type(number) is not int or number <= 0: + raise ValueError("Missing qualified draft review issue") + issue = api.request(f"repos/{plan['repository']}/issues/{number}") + if plan.get("draft_review_comment"): + import release_issue_topology as threads + from notation_release_controller import HOST, controller_body, decode_issue + actor = os.environ.get("MONTHLY_PATCH_ACTOR") + root = api.request(f"repos/{HOST}/issues/{plan['controller_issue']}") + state = decode_issue(root, actor) + if (state is None or root.get("body") != controller_body(state) + or state["plan"]["plan_id"] != plan["controller_plan_id"] + or state.get("release_issues", {}).get(plan["repository"]) != number): + raise ValueError("Qualified draft release issue route changed") + trusted_editor(api, HOST, root) + if plan["repository"] != HOST: + threads.checked_issue(api, actor, plan["repository"], number, + threads.scope_body(state["plan"], plan["repository"], state["release_issues"])) + comment = api.request(f"repos/{plan['repository']}/issues/comments/{plan['draft_review_comment']}") + if (comment.get("user", {}).get("login") != actor + or comment.get("body") != plan["draft_review_body"] + or comment.get("issue_url") != f"https://api.github.com/repos/{plan['repository']}/issues/{number}"): + raise ValueError("Qualified draft review comment changed") + return issue + if ("pull_request" in issue or issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR") + or issue.get("body") != draft_review_body(plan)): + raise ValueError("Qualified draft review issue changed or has an untrusted author") + trusted_editor(api, plan["repository"], issue) + return issue + + +def assert_tag(api, plan): + current = verified_tag(api, plan["repository"], plan["tag"], plan["commit"]) + if plan.get("tag_object") and plan["tag_object"] != current: + raise ValueError("Immutable annotated tag object changed") + return current + + +def signing_environment(path, api): + key = os.environ.get("MONTHLY_PATCH_SIGNING_KEY", "") + email = os.environ.get("MONTHLY_PATCH_SIGNER_EMAIL", "") + if not key or not email or "\n" in email: + raise ValueError("A dedicated registered SSH signing key and signer email are required") + key_path = pathlib.Path(path) / "signing-key" + descriptor = os.open(key_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(descriptor, "w") as output: + output.write(key.rstrip() + "\n") + public = run(["ssh-keygen", "-y", "-P", "", "-f", str(key_path)]).split()[:2] + login = os.environ.get("MONTHLY_PATCH_SIGNER_LOGIN", "") + if not re.fullmatch(r"[A-Za-z0-9-]+", login): + raise ValueError("A registered GitHub signing-key owner is required") + keys = list(api.pages(f"users/{login}/ssh_signing_keys")) + if not any(item["key"].split()[:2] == public for item in keys): + raise ValueError("Release key is not registered as the declared GitHub user's SSH signing key") + settings = { + "user.name": os.environ.get("MONTHLY_PATCH_SIGNER_NAME") or "Monthly dependency release", + "user.email": email, "gpg.format": "ssh", "user.signingkey": str(key_path), + "commit.gpgsign": "true", + } + environment = os.environ.copy() + environment["GIT_CONFIG_COUNT"] = str(len(settings)) + for index, (name, value) in enumerate(settings.items()): + environment[f"GIT_CONFIG_KEY_{index}"] = name + environment[f"GIT_CONFIG_VALUE_{index}"] = value + return environment + + +def git(command, directory, environment=None): + return run([ + "git", "-c", "credential.helper=", "-c", + "credential.helper=!gh auth git-credential", *command, + ], cwd=directory, env=environment).strip() + + +def baseline_plan(api, repository, month, mode, state=None): + source = repository + baseline = latest_release(list(api.pages(f"repos/{source}/releases"))) + major, minor, patch = semver(baseline["tag_name"]) + branch = f"release-{major}.{minor}" + tag = f"v{major}.{minor}.{patch + 1}" + if state and state["baseline"] != baseline["tag_name"]: + raise ValueError("Stable baseline advanced during the cycle; replan before any release") + return { + "schema": 1, "repository": repository, "month": month, "mode": mode, + "baseline": baseline["tag_name"], "baseline_published": baseline["published_at"], + "branch": branch, "main": "main", + "tag": tag, "status": "merging", "merged": [], + "started_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + + +def inventory(api, repository, branch, authorization=None, merged=()): + result = [] + for pull in api.pulls(repository, branch, "closed"): + eligible = dependabot(pull, branch) + if not eligible and authorization: + from notation_release_controller import scoped_pull + recorded = any(item["number"] == pull["number"] for item in merged) + eligible = (scoped_pull(api, repository, pull, authorization, merged) + if recorded and pull.get("merged_at") else + False) + if eligible and pull.get("merged_at"): + if not SHA.fullmatch(pull.get("merge_commit_sha") or ""): + raise ValueError("Merged Dependabot PR has no valid source commit") + result.append({ + "number": pull["number"], "commit": pull["merge_commit_sha"], + "merged_at": pull["merged_at"], + }) + return sorted(result, key=lambda item: (item["merged_at"], item["number"])) + + +def check_files(api, repository, number): + files = list(api.pages(f"repos/{repository}/pulls/{number}/files")) + if not files or not all( + dependency_file(item["filename"]) + and (not item.get("previous_filename") or dependency_file(item["previous_filename"])) + for item in files + ): + raise ValueError(f"Dependabot PR #{number} changes files outside the dependency allowlist") + + +def check_publisher_guard(api, repository, branch): + if repository.split("/")[1] != "notation": + return + workflow = api.optional(f"repos/{repository}/contents/.github/workflows/release-github.yml?ref={branch}") + if workflow is None: + return + if workflow.get("encoding") != "base64": + raise ValueError("Cannot inspect the release branch's existing tag publisher") + content = base64.b64decode(workflow["content"]).decode("utf-8") + guard = " if: github.repository == 'notaryproject/notation' && github.actor != vars.MONTHLY_PATCH_ACTOR" + if guard not in content.splitlines(): + raise ValueError("Install the monthly automation-actor guard in release-github.yml on the release branch before enabling monthly publication") + + +def coordinator_authorization(api, repository, mode, month, plan=None, allow_previous=False, notification=False): + if mode == "dry-run": + return None + from notation_release_controller import worker_authorization + return worker_authorization(api, repository, month, plan, allow_previous, notification) + + +def candidate_bundle(directory, output, state): + git(["fetch", "--no-tags", "origin", state["commit"]], directory) + git(["update-ref", "refs/heads/monthly-patch-candidate", state["commit"]], directory) + git(["bundle", "create", str(pathlib.Path(output) / "candidate.bundle"), + "refs/heads/monthly-patch-candidate"], directory) + return {**state, "action": "qualify"} + + +def prepare(api, repository, mode, month, directory, output, start=False, reconcile=False): + metadata = api.request(f"repos/{repository}") + policy(repository, mode, os.environ, metadata) + authorization = coordinator_authorization(api, repository, mode, month) + effective_mode = "execute" + active = resume_cycle(api, repository, month, effective_mode) + if authorization and active and active != (month, "monthly"): + raise ValueError("Another release cycle is active; coordinator authorization cannot replace it") + cycle_id = "monthly" + if active: + month, cycle_id = active + cycle = Cycle(api, repository, month, effective_mode, cycle_id) + if authorization and cycle.state: + coordinator_authorization(api, repository, mode, month, cycle.state, allow_previous=True) + if cycle.state.get("controller_plan_id") != authorization["plan_id"] and cycle.state["status"] in { + "merging", "waiting", "voting", "ready", "draft", "awaiting-publication", "verifying", + }: + cycle.state["controller_plan_id"] = authorization["plan_id"] + if mode == "execute" and cycle.state["status"] not in {"draft", "awaiting-publication", "verifying"}: + roster = authorization["snapshot"]["automation_sha"] + if cycle.state.get("release_vote"): + from monthly_release_pr import content + vote = cycle.state["release_vote"] + if content(api, repository, "MAINTAINERS", roster) != content(api, repository, "MAINTAINERS", vote["maintainer_roster_sha"]): + raise ValueError("Maintainer roster changed; a new release vote is required") + vote["maintainer_roster_sha"] = roster + cycle.state["maintainer_roster_sha"] = roster + cycle.save(cycle.state) + discovered = None + if not active and reconcile and mode != "dry-run": + pending = pending_notification(api, repository, effective_mode) + if pending: + return {**pending, "action": "complete"} + if not active and reconcile and (mode != "dry-run" or cycle.state) and (not start or (cycle.state and cycle.state["status"] in FINAL)): + discovered = producer_versions(api, repository, effective_mode) + baseline = baseline_plan(api, repository, month, mode) + ref = baseline["baseline"] + if producer_lag(api, repository, ref, effective_mode, discovered, at_least=True): + cycle_id = upstream_cycle_id(discovered) + cycle = Cycle(api, repository, month, effective_mode, cycle_id) + if cycle.state and cycle.state["status"] in FINAL: + raise ValueError("Completed upstream cycle is missing from the current release; reconcile before publishing") + elif not cycle.state: + return {"action": "idle", "reason": "No active cycle or unconsumed public producer release"} + if mode == "dry-run" and cycle.state: + return {**cycle.state, "action": "preview", "writes": False} + if cycle.state and cycle.state["status"] in FINAL: + return {**cycle.state, "action": "complete"} + if mode != "dry-run" and not cycle.state and not start and cycle_id == "monthly": + return {"action": "idle", "reason": "No active monthly cycle to resume"} + if mode == "execute" and authorization and cycle.state and cycle.state["status"] == "voting": + from monthly_release_pr import advance + with tempfile.TemporaryDirectory(prefix="monthly-vote-signing-") as signing: + result = advance(api, cycle.state, directory, signing_environment(signing, api), cycle) + return candidate_bundle(directory, output, result) if result["action"] == "qualify" else result + if cycle.state and cycle.state["status"] in {"tagged", "staging", "draft", "awaiting-publication", "verifying"}: + assert_tag(api, cycle.state) + if cycle.state["status"] in {"awaiting-publication", "verifying"}: + check_draft_review(api, cycle.state) + remote = api.request(f"repos/{repository}/releases/{cycle.state['release_id']}") + if remote.get("draft") is True: + check_release_assets(api, cycle.state, True) + return {**cycle.state, "action": "waiting", "reason": "Qualified draft awaits maintainer publication"} + check_public_assets(api, cycle.state) + cycle.state["status"] = "verifying" + cycle.save(cycle.state) + return {**cycle.state, "action": "verify"} + if cycle.state["status"] == "draft": + check_release_assets(api, cycle.state, True) + return {**cycle.state, "action": "draft-check"} + return {**cycle.state, "action": "package"} + if cycle.state and cycle.state["status"] == "ready": + existing_tag = api.optional(f"repos/{repository}/git/ref/tags/{cycle.state['tag']}") + if existing_tag: + cycle.state["tag_object"] = assert_tag(api, cycle.state) + cycle.state["status"] = "tagged" + cycle.save(cycle.state) + return {**cycle.state, "action": "package"} + if mode == "execute" and authorization and cycle.state.get("release_backport"): + from monthly_release_pr import validate + validate(api, cycle.state) + return candidate_bundle(directory, output, cycle.state) + state = cycle.state or baseline_plan(api, repository, month, mode) + state["cycle_id"] = cycle_id + if authorization: + snapshot = authorization["snapshot"] + if any(state[key] != snapshot[key] for key in ("baseline", "main", "branch", "tag")): + raise ValueError("Assessed release baseline or proposed patch version changed") + state.update(controller_issue=authorization["issue"], controller_plan_id=authorization["plan_id"]) + if authorization.get("release_issue"): + state["release_issue"] = authorization["release_issue"] + if mode == "execute": + state["maintainer_roster_sha"] = snapshot["automation_sha"] + baseline_plan(api, repository, month, mode, state) + branch = api.optional(f"repos/{repository}/branches/{state['branch']}") + main = api.optional(f"repos/{repository}/branches/{state['main']}") + if branch is None or main is None: + return {**state, "action": "blocked", "reason": "Reviewed main/release branches must exist before execution"} + if authorization and not cycle.state and ( + branch["commit"]["sha"] != snapshot["release_head"] + or main["commit"]["sha"] != snapshot["main_head"] + ): + raise ValueError("Assessed source refs changed before worker execution") + if mode == "execute": + check_publisher_guard(api, repository, state["branch"]) + producers = (authorization["producers"] if authorization else + discovered if discovered is not None else producer_versions(api, repository, effective_mode)) + state["producers"] = producers + def eligible(pull): + if dependabot(pull, state["main"]): + return True + if authorization: + return False + return False + open_pulls = sorted([item for item in api.pulls(repository, state["main"], "open") if eligible(item)], + key=lambda item: item["number"]) + if authorization: + from notation_release_controller import scoped_pull + open_pulls = [pull for pull in open_pulls if scoped_pull(api, repository, pull, authorization)] + if mode == "dry-run": + previews = [] + for pull in open_pulls: + check_files(api, repository, pull["number"]) + ready, reason = checks_ready(pull, api.checks(repository, pull["number"])) + previews.append({"number": pull["number"], "ready": ready, "reason": reason}) + return {**state, "action": "preview", "pulls": previews, + "producer_waits": producer_lag(api, repository, state["main"], effective_mode, producers), + "writes": False} + with tempfile.TemporaryDirectory(prefix="monthly-signing-") as signing: + environment = signing_environment(signing, api) + cycle.save(state) + blocked = [] + for initial in open_pulls: + pull = api.request(f"repos/{repository}/pulls/{initial['number']}") + if not eligible(pull) or pull.get("state") != "open": + raise ValueError("PR identity changed after discovery; retry from a fresh inventory") + if pull["head"]["repo"]["full_name"] != repository: + raise ValueError("Dependabot head is outside the target repository") + if authorization and not scoped_pull(api, repository, pull, authorization): + raise ValueError("Dependency PR changed after coordinator scope validation") + check_files(api, repository, pull["number"]) + ready, reason = checks_ready(pull, api.checks(repository, pull["number"])) + if not ready: + blocked.append(f"Dependabot #{pull['number']}: {reason}") + continue + merged = api.request( + f"repos/{repository}/pulls/{pull['number']}/merge", "PUT", + {"sha": pull["head"]["sha"], "merge_method": "squash"}, + ) + if merged.get("merged") is not True or not SHA.fullmatch(merged.get("sha") or ""): + raise ValueError("GitHub did not confirm a normal, non-bypass squash merge") + state["merged"].append({"number": pull["number"], "commit": merged["sha"]}) + cycle.save(state) + if blocked: + state.update(status="waiting", reason="; ".join(blocked)) + cycle.save(state) + return {**state, "action": "waiting"} + waiting = producer_lag(api, repository, state["main"], mode, producers) + if waiting: + state.update(status="waiting", reason="; ".join(waiting)) + cycle.save(state) + return {**state, "action": "waiting"} + merged_pulls = inventory(api, repository, state["main"], authorization, state["merged"]) + if authorization: + merged_pulls = [ + item for item in merged_pulls + if scoped_pull(api, repository, api.request(f"repos/{repository}/pulls/{item['number']}"), authorization, state["merged"]) + ] + if not merged_pulls: + if cycle_id != "monthly": + raise ValueError("Required producer update has no merged Dependabot backport provenance") + state.update(status="skipped", reason="No merged dependency PRs") + cycle.save(state) + return {**state, "action": "complete"} + git(["fetch", "--no-tags", "origin", + f"+refs/heads/{state['main']}:refs/remotes/origin/{state['main']}", + f"+refs/heads/{state['branch']}:refs/remotes/origin/{state['branch']}"], directory) + expected = branch["commit"]["sha"] + if git(["rev-parse", f"refs/remotes/origin/{state['branch']}"], directory) != expected: + raise ValueError("Release branch changed during preparation; retry before backporting") + common = git(["merge-base", f"refs/remotes/origin/{state['main']}", expected], directory) + source_commits = set(git(["rev-list", f"{common}..refs/remotes/origin/{state['main']}"], directory).splitlines()) + merged_pulls = [item for item in merged_pulls if item["commit"] in source_commits] + for item in merged_pulls: + check_files(api, repository, item["number"]) + worktree = pathlib.Path(output) / "candidate" + git(["worktree", "add", "--detach", str(worktree), expected], directory) + environment["GIT_COMMITTER_DATE"] = state["started_at"] + history = git(["log", "--format=%H%x00%B%x00", f"refs/remotes/origin/{state['branch']}"], directory) + backports = [] + for item in merged_pulls: + if f"(cherry picked from commit {item['commit']})" in history: + records = history.split("\x00") + backport = next( + records[index - 1].strip() + for index in range(1, len(records), 2) + if f"(cherry picked from commit {item['commit']})" in records[index] + ) + if f"This reverts commit {backport}." in history: + raise ValueError(f"Backport #{item['number']} was reverted; reconcile before releasing") + continue + ancestors = subprocess.run(["git", "merge-base", "--is-ancestor", item["commit"], expected], cwd=directory) + if ancestors.returncode == 0: + continue + if ancestors.returncode != 1: + raise ValueError("Could not establish dependency commit ancestry") + cherry = subprocess.run( + ["git", "cherry-pick", "-x", "-S", item["commit"]], + cwd=worktree, env=environment, capture_output=True, text=True, + ) + if cherry.returncode: + if not git(["ls-files", "--unmerged"], worktree) and not git(["diff", "--cached", "--name-only"], worktree): + git(["cherry-pick", "--skip"], worktree, environment) + else: + raise ValueError(f"Dependency backport #{item['number']} failed:\n{cherry.stdout}{cherry.stderr}") + else: + backports.append({**item, "backport_commit": git(["rev-parse", "HEAD"], worktree)}) + candidate = git(["rev-parse", "HEAD"], worktree) + if candidate == expected and cycle_id == "monthly": + state.update(status="skipped", reason="All dependency updates are already on the release branch") + cycle.save(state) + return {**state, "action": "complete"} + state.update( + status="ready", reason="", merged=merged_pulls, backports=backports, previous_head=expected, + commit=candidate, + ) + root_manifest = json.loads(run(["go", "mod", "edit", "-json"], cwd=worktree)) + if mode == "execute" and root_manifest.get("Replace"): + raise ValueError("Production release manifests must not contain trial replacements") + if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", root_manifest["Go"]): + raise ValueError("Unsupported minimum Go version") + state["minimum_go"] = ".".join(root_manifest["Go"].split(".")[:2]) + for name, producer in producers.items(): + for module_directory in modules(repository): + manifest = json.loads(run(["go", "mod", "edit", "-json"], cwd=worktree / module_directory)) + module = f"github.com/notaryproject/{name}" + if module_directory == "." or any(item["Path"] == module for item in manifest.get("Require") or []): + if requirement(manifest, module, producer["repository"], mode) != producer["version"]: + raise ValueError("Release backport does not consume the required public producer version") + if mode == "execute" and authorization: + from monthly_release_pr import advance + state = advance(api, state, directory, environment, cycle) + if state["action"] != "qualify": + return state + cycle.save(state) + git(["update-ref", "refs/heads/monthly-patch-candidate", state["commit"]], directory) + git(["bundle", "create", str(pathlib.Path(output) / "candidate.bundle"), + "refs/heads/monthly-patch-candidate"], directory) + return {**state, "action": "qualify"} + + +def import_candidate(directory, plan, bundle): + validate_plan(plan) + if not SHA.fullmatch(plan["commit"]): + raise ValueError("Invalid qualified candidate commit") + git(["fetch", str(bundle), "refs/heads/monthly-patch-candidate"], directory) + if git(["rev-parse", "FETCH_HEAD"], directory) != plan["commit"]: + raise ValueError("Candidate bundle does not match the prepared commit") + git(["checkout", "--detach", plan["commit"]], directory) + if git(["status", "--porcelain"], directory): + raise ValueError("Candidate checkout is dirty") + + +def stage_tag(api, plan, directory): + validate_plan(plan) + policy(plan["repository"], plan["mode"], os.environ, api.request(f"repos/{plan['repository']}")) + coordinator_authorization(api, plan["repository"], plan["mode"], plan["month"], plan) + if plan["mode"] == "execute" and plan.get("controller_issue"): + from monthly_release_pr import validate + validate(api, plan) + if plan["status"] != "ready": + raise ValueError("Only a qualified ready candidate can be tagged") + repository = plan["repository"] + cycle = Cycle(api, repository, plan["month"], plan["mode"], plan.get("cycle_id", "monthly")) + if not cycle.state or cycle.state.get("commit") != plan["commit"]: + raise ValueError("Prepared cycle state changed before publication") + if cycle.state["status"] != "ready" or cycle.state["tag"] != plan["tag"] or cycle.state["previous_head"] != plan["previous_head"]: + raise ValueError("Cycle changed before tagging") + baseline_plan(api, repository, plan["month"], plan["mode"], plan) + if git(["rev-parse", "HEAD"], directory) != plan["commit"] or git(["status", "--porcelain"], directory): + raise ValueError("Tagging requires the exact clean qualified candidate") + if api.request(f"repos/{repository}/branches/{plan['branch']}")["commit"]["sha"] != plan["previous_head"]: + raise ValueError("Release branch advanced during qualification") + with tempfile.TemporaryDirectory(prefix="monthly-local-tag-") as signing: + environment = signing_environment(signing, api) + git(["tag", "-s", plan["tag"], plan["commit"], "-m", + f"Monthly dependency patch {plan['month']}\nQualified commit: {plan['commit']}"], directory, environment) + return plan + + +def tag_candidate(api, plan, directory): + validate_plan(plan) + policy(plan["repository"], plan["mode"], os.environ, api.request(f"repos/{plan['repository']}")) + coordinator_authorization(api, plan["repository"], plan["mode"], plan["month"], plan) + if plan["mode"] == "execute" and plan.get("controller_issue"): + from monthly_release_pr import validate + validate(api, plan) + repository = plan["repository"] + cycle = Cycle(api, repository, plan["month"], plan["mode"], plan.get("cycle_id", "monthly")) + if not cycle.state or any(cycle.state.get(key) != plan.get(key) for key in ("commit", "tag", "previous_head", "status")) or plan["status"] != "ready": + raise ValueError("Qualified cycle changed before remote tagging") + baseline_plan(api, repository, plan["month"], plan["mode"], plan) + if git(["rev-parse", "HEAD"], directory) != plan["commit"] or git(["status", "--porcelain", "--untracked-files=no"], directory): + raise ValueError("Qualified source changed during packaging") + if git(["rev-parse", f"{plan['tag']}^{{commit}}"], directory) != plan["commit"]: + raise ValueError("Local release tag does not identify the qualified commit") + ref = api.optional(f"repos/{repository}/git/ref/tags/{plan['tag']}") + if ref: + assert_tag(api, plan) + else: + with tempfile.TemporaryDirectory(prefix="monthly-tag-signing-") as signing: + environment = signing_environment(signing, api) + git(["-c", "push.followTags=false", "push", "--atomic", + f"--force-with-lease=refs/heads/{plan['branch']}:{plan['previous_head']}", + f"--force-with-lease=refs/tags/{plan['tag']}:", + "origin", f"{plan['previous_head'] if plan.get('release_backport') else plan['commit']}:refs/heads/{plan['branch']}", + f"refs/tags/{plan['tag']}:refs/tags/{plan['tag']}"], directory, environment) + assert_tag(api, plan) + plan["tag_object"] = assert_tag(api, plan) + plan["status"] = "tagged" + cycle.save(plan) + return {**plan, "action": "package"} + + +def publish(api, plan, directory): + """Stage a true draft only. Automation never makes a release public.""" + validate_plan(plan) + policy(plan["repository"], plan["mode"], os.environ, api.request(f"repos/{plan['repository']}")) + authorization = coordinator_authorization(api, plan["repository"], plan["mode"], plan["month"], plan) + if authorization and authorization["snapshot"].get("verification_recovery") is not None: + raise ValueError("Verification-only recovery cannot build or republish release assets") + if plan["mode"] == "execute" and plan.get("controller_issue"): + from monthly_release_pr import validate + validate(api, plan) + assert_tag(api, plan) + cycle = Cycle(api, plan["repository"], plan["month"], plan["mode"], plan.get("cycle_id", "monthly")) + if (not cycle.state or cycle.state["status"] not in {"tagged", "staging"} + or any(cycle.state.get(key) != plan.get(key) for key in ("commit", "tag", "previous_head"))): + raise ValueError("Cycle changed before recording draft release") + assets = [] + for path in sorted(pathlib.Path(directory).iterdir()): + if path.is_file() and (path.name.endswith((".tar.gz", ".zip", "checksums.txt"))): + assets.append({"name": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "size": path.stat().st_size}) + count = 7 if plan["repository"].endswith("/notation") else 2 + if len(assets) != count: + raise ValueError("Unexpected release asset set") + if {item["name"] for item in assets} != set(asset_names(plan["repository"].split("/")[1], plan["tag"])): + raise ValueError("Unexpected release asset names") + notes = ( + f"# {plan['repository'].split('/')[1]} {plan['tag']}\n\n" + + f"{plan_marker(plan)}\n" + + f"Qualified commit: {plan['commit']}\n" + + f"Previous stable tag: {plan['baseline']}\n\n" + + "Dependabot backports:\n" + + "\n".join(f"* #{item['number']}: {item['commit']}" for item in plan["merged"]) + + "\n\nAsset verification manifest:\n```json\n" + json.dumps(assets, indent=2) + "\n```\n" + + "\nQualification and review are recorded in the separate qualified draft review issue. " + + "Do not publish unless that issue exists and its exact-asset checks passed.\n" + ) + if cycle.state["status"] == "staging": + plan = copy.deepcopy(cycle.state) + recorded = [{key: item[key] for key in ("name", "sha256", "size")} for item in plan["assets"]] + if recorded != assets: + raise ValueError("Retry must retain the exact originally staged asset bytes") + existing = api.request(f"repos/{plan['repository']}/releases/{plan['release_id']}") + if (existing.get("id") != plan["release_id"] or existing.get("draft") is not True + or existing.get("prerelease") is not False or existing.get("tag_name") != plan["tag"] + or existing.get("body") != notes): + raise ValueError("Recorded draft identity or qualification manifest changed") + else: + if any(item["tag_name"] == plan["tag"] for item in api.pages(f"repos/{plan['repository']}/releases")): + raise ValueError("Unrecorded existing release requires reconciliation; never adopt or replace it") + existing = api.request(f"repos/{plan['repository']}/releases", "POST", { + "tag_name": plan["tag"], "target_commitish": plan["commit"], "name": plan["tag"], + "body": notes, "draft": True, "prerelease": False, "make_latest": "false", + }) + identifier = existing.get("id") + if type(identifier) is not int or identifier <= 0 or existing.get("draft") is not True: + raise ValueError("Draft creation was not confirmed") + plan.update(status="staging", release_id=identifier, assets=assets) + cycle.save(plan) + remote = list(api.pages(f"repos/{plan['repository']}/releases/{plan['release_id']}/assets")) + if len({item["name"] for item in remote}) != len(remote) or any(item["name"] not in {a["name"] for a in assets} for item in remote): + raise ValueError("Unexpected or duplicate staged draft assets") + for item in plan["assets"]: + found = next((a for a in remote if a["name"] == item["name"]), None) + if found is None: + from urllib.parse import quote + found = json.loads(run([ + "gh", "api", "--method", "POST", + f"https://uploads.github.com/repos/{plan['repository']}/releases/{plan['release_id']}/assets?name={quote(item['name'], safe='')}", + "-H", "Content-Type: application/octet-stream", "--input", str(pathlib.Path(directory) / item["name"]), + ])) + if (type(found.get("id")) is not int or found["id"] <= 0 + or ("id" in item and item["id"] != found["id"]) or found.get("name") != item["name"] + or found.get("size") != item["size"] or found.get("digest") != f"sha256:{item['sha256']}"): + raise ValueError("Draft uploaded asset differs from the qualified bytes") + item["id"] = found["id"] + cycle.save(plan) + plan.update(status="draft", reason="Draft download/native verification is pending; publication is not authorized") + check_release_assets(api, plan, True) + cycle.save(plan) + return {**plan, "action": "draft-check"} + + +def complete(api, plan): + validate_plan(plan) + policy(plan["repository"], plan["mode"], os.environ, api.request(f"repos/{plan['repository']}")) + coordinator_authorization(api, plan["repository"], plan["mode"], plan["month"], plan) + cycle = Cycle(api, plan["repository"], plan["month"], plan["mode"], plan.get("cycle_id", "monthly")) + if (not cycle.state or cycle.state["status"] not in {"draft", "verifying"} + or any(cycle.state.get(key) != plan.get(key) for key in ("status", "commit", "tag", "release_id", "assets"))): + raise ValueError("No matching draft/public release awaiting successful verification") + plan = copy.deepcopy(cycle.state) + assert_tag(api, plan) + if plan["status"] == "draft": + check_release_assets(api, plan, True) + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not run_id.isdecimal() or int(run_id) <= 0: + raise ValueError("Qualified draft requires its native verification run") + if not plan.get("draft_verification"): + plan["draft_plan_id"] = plan["controller_plan_id"] + plan["draft_verification"] = { + "run_id": int(run_id), "worker_sha": os.environ["MONTHLY_PATCH_WORKER_SHA"], + "platforms": ["linux_amd64", "darwin_arm64", "windows_amd64"], + "evidence": f"https://github.com/{plan['repository']}/actions/runs/{run_id}", + "artifacts": ["monthly-source-*", "monthly-build-evidence", "monthly-draft-*"], + } + cycle.save(plan) + body = draft_review_body(plan) + from notation_release_controller import HOST, controller_body, decode_issue + import release_issue_topology as threads + actor = os.environ.get("MONTHLY_PATCH_ACTOR") + root = (api.request(f"repos/{HOST}/issues/{plan['controller_issue']}") + if plan.get("release_issue") else None) + state = decode_issue(root, actor) if root else None + if plan.get("release_issue") and (state is None or not state.get("release_issues")): + raise ValueError("Qualified draft release issue topology is missing") + if state is not None and state.get("release_issues"): + if root.get("body") != controller_body(state) or state["plan"]["plan_id"] != plan["controller_plan_id"]: + raise ValueError("Qualified draft plan issue changed") + trusted_editor(api, HOST, root) + number = state["release_issues"][plan["repository"]] + if plan["repository"] != HOST: + threads.checked_issue(api, actor, plan["repository"], number, + threads.scope_body(state["plan"], plan["repository"], state["release_issues"])) + main = api.request(f"repos/{plan['repository']}/branches/main")["commit"]["sha"] + from notation_release_controller import repository_content + from monthly_release_vote import parse_repo_maintainers + roster = parse_repo_maintainers(repository_content(api, plan["repository"], "MAINTAINERS", main)) + body = " ".join(f"@{login}" for login in sorted(roster)) + "\n\n" + body + identity = f"draft-ready-{plan['repository']}:{plan['tag']}:{plan['release_id']}" + comment = threads.comment_once(api, actor, plan["repository"], number, identity, body) + plan.update(draft_review_issue=number, draft_review_comment=comment["id"], + draft_review_body=comment["body"], status="awaiting-publication", + reason="Qualified draft awaits maintainer Publish release action") + cycle.save(plan) + check_draft_review(api, plan) + return {**plan, "action": "waiting"} + marker = body.split("\n", 1)[0] + reviews = [issue for issue in api.pages(f"repos/{plan['repository']}/issues?state=all") + if marker in (issue.get("body") or "")] + if len(reviews) > 1: + raise ValueError("Duplicate draft review issues require reconciliation") + if reviews: + issue = reviews[0] + plan["draft_review_issue"] = issue["number"] + check_draft_review(api, plan) + else: + issue = api.request(f"repos/{plan['repository']}/issues", "POST", { + "title": f"Review qualified draft: {plan['tag']} ({plan['month']})", + "body": body, "state": "open", + }) + plan["draft_review_issue"] = issue["number"] + check_draft_review(api, plan) + plan.update(status="awaiting-publication", reason="Qualified draft awaits maintainer Publish release action") + cycle.save(plan) + return {**plan, "action": "waiting"} + check_public_assets(api, plan) + plan.update(status="published", reason="Source, artifacts, and public downloaded-package checks passed") + cycle.save(plan) + return {**plan, "action": "complete"} + + +def dispatch_consumers(api, repository, mode, tag, notified=None): + owner, producer = repository.split("/") + consumers = [f"{owner}/{name}" for name, dependencies in PROJECTS.items() if producer in dependencies] + notified = list(notified or []) + if len(set(notified)) != len(notified) or any(target not in consumers for target in notified): + raise ValueError("Invalid downstream notification progress") + for target in consumers: + if target in notified: + continue + api.request(f"repos/{target}/dispatches", "POST", { + "event_type": "dependency-patch-upstream", + "client_payload": {"producer": repository, "tag": tag, "mode": mode}, + }) + notified.append(target) + yield list(notified) + + +def notify(api, plan): + validate_plan(plan) + policy(plan["repository"], plan["mode"], os.environ, api.request(f"repos/{plan['repository']}")) + cycle = Cycle(api, plan["repository"], plan["month"], plan["mode"], plan.get("cycle_id", "monthly")) + if not cycle.state or cycle.state["status"] != "published" or any(cycle.state.get(key) != plan.get(key) for key in ("tag", "commit")): + raise ValueError("Only a successfully verified public cycle can notify consumers") + assert_tag(api, cycle.state) + check_public_assets(api, cycle.state) + for notified in dispatch_consumers(api, plan["repository"], plan["mode"], plan["tag"], cycle.state.get("notified")): + cycle.state["notified"] = notified + cycle.save(cycle.state) + return {**cycle.state, "action": "complete"} + + +def announce(api, repository, mode, tag): + if not REPOSITORY.fullmatch(repository) or mode != "execute": + raise ValueError("Invalid release notification repository or mode") + metadata = api.request(f"repos/{repository}") + if mode == "execute": + if repository.split("/")[0] != "notaryproject" or os.environ.get("MONTHLY_PATCH_ENABLED") != "true": + raise ValueError("Canonical release notifications are not enabled") + if os.environ.get("MONTHLY_PATCH_TOKEN_READY") != "true" or not os.environ.get("MONTHLY_PATCH_ACTOR"): + raise ValueError("Release notifications require the configured automation credential") + if os.environ.get("GITHUB_REF") != f"refs/tags/{tag}": + raise ValueError("Release notification must identify the triggering public tag") + release = api.request(f"repos/{repository}/releases/tags/{tag}") + if release["draft"] or release["tag_name"] != tag: + raise ValueError("Release notification does not identify a public release") + if "" + remote = api.optional(f"repos/{repository}/git/ref/heads/{expected['head_ref']}") + if remote is not None: + if remote["object"]["sha"] != expected["head_sha"]: + raise ValueError("Reserved release PR branch changed; never overwrite it") + else: + release.git(["-c", "push.followTags=false", "push", + f"--force-with-lease=refs/heads/{expected['head_ref']}:", + "origin", f"{expected['head_sha']}:refs/heads/{expected['head_ref']}"], directory, environment) + commit_identity(api, repository, expected["head_sha"], expected["head_tree"]) + pulls = [pull for state in ("open", "closed") for pull in api.pulls(repository, expected["base_ref"], state) + if pull["head"]["ref"] == expected["head_ref"]] + if len(pulls) > 1: + raise ValueError("Duplicate release PRs require reconciliation") + if pulls: + pull = pulls[0] + if pull.get("user", {}).get("login") != expected["actor"] or marker not in (pull.get("body") or ""): + raise ValueError("Reserved release PR has an untrusted identity") + else: + pull = api.request(f"repos/{repository}/pulls", "POST", { + "title": title, "head": expected["head_ref"], "base": expected["base_ref"], + "body": ( + f"{marker}\n\n" + f"Monthly patch `{plan['tag']}` for approved controller plan `{plan['controller_plan_id']}`.\n\n" + f"[Change logs](https://github.com/{repository}/compare/{plan['baseline']}...{expected['head_sha']})\n\n" + f"[Repo-level maintainers at the PR base](https://github.com/{repository}/blob/{expected['base_sha']}/MAINTAINERS)\n\n" + f"[Pinned assessed roster](https://github.com/{repository}/blob/{plan['maintainer_roster_sha']}/MAINTAINERS)\n\n" + "This PR preserves ordinary CI and branch reviews. " + + ("The separate release-voting PR requires a strict majority of repo-level " + "maintainer approvals and a normal merge commit. " + if repository != "notaryproject/notation-core-go" else + "Core uses its reviewed dependency-only policy and normal merge commit, without an invented metadata vote. ") + + + "Approval does not authorize optional source fixes or features." + ), + }) + expected["number"] = release_id(pull["number"]) + return pull + + +def release_id(value): + if type(value) is not int or value <= 0: + raise ValueError("Invalid release PR number") + return value + + +def pull_identity(pull, expected): + if (pull["number"] != expected["number"] + or pull.get("user", {}).get("login") != expected["actor"] + or pull["base"]["ref"] != expected["base_ref"] + or pull["head"]["sha"] != expected["head_sha"] + or pull["head"]["ref"] != expected["head_ref"] + or (pull["head"].get("repo") or {}).get("full_name") != expected["repository"]): + raise ValueError("Release PR identity or candidate changed") + + +def merge_pull(api, expected, pull, require_tip=True): + pull_identity(pull, expected) + repository = expected["repository"] + if pull.get("merged_at"): + sha = pull.get("merge_commit_sha", "") + if not release.SHA.fullmatch(sha): + raise ValueError("Merged release PR has no exact merge commit") + commit = api.request(f"repos/{repository}/commits/{sha}") + if ([item["sha"] for item in commit["parents"]] != [expected["base_sha"], expected["head_sha"]] + or commit_identity(api, repository, sha, expected["head_tree"]) != expected["head_tree"]): + raise ValueError("Release PR was not merged with the voted tree and exact normal merge parents") + if require_tip and api.request(f"repos/{repository}/branches/{expected['base_ref']}")["commit"]["sha"] != sha: + raise ValueError("Release branch changed after its approved PR merge") + return sha + if pull.get("state") != "open": + raise ValueError("Release PR was closed without its required normal merge") + if api.request(f"repos/{repository}/branches/{expected['base_ref']}")["commit"]["sha"] != expected["base_sha"]: + raise ValueError("Release branch advanced outside the approved release PR") + ready, reason = release.checks_ready(pull, api.checks(repository, expected["number"])) + if not ready: + return None + result = api.request(f"repos/{repository}/pulls/{expected['number']}/merge", "PUT", { + "sha": expected["head_sha"], "merge_method": "merge", + }) + if result.get("merged") is not True: + raise ValueError("GitHub refused the normal release PR merge") + merged = api.request(f"repos/{repository}/pulls/{expected['number']}") + return merge_pull(api, expected, merged) + + +def version_metadata(repository, original, tag): + if repository.endswith("/notation-go"): + pattern = r'(const signingAgent\s*=\s*")[^"]+(")' + replacement = "notation-go/" + tag.removeprefix("v") + if len(re.findall(pattern, original)) != 1: + raise ValueError("Expected one documented signingAgent declaration") + return re.sub(pattern, lambda match: match[1] + replacement + match[2], original) + if repository.endswith("/notation"): + result = original + for field, value in (("Version", tag), ("BuildMetadata", "")): + pattern = rf'(?m)^(\s*{field}\s*=\s*")[^"]*(")' + if len(re.findall(pattern, result)) != 1: + raise ValueError(f"Expected one documented release {field} declaration") + result = re.sub(pattern, lambda match: match[1] + value + match[2], result) + return result + raise ValueError("This repository has no documented version-metadata voting step") + + +def advance(api, plan, directory, environment, cycle): + repository = plan["repository"] + if plan["mode"] != "execute": + raise ValueError("Release voting is restricted to canonical execution") + if "release_backport" not in plan: + tree = release.git(["rev-parse", f"{plan['commit']}^{{tree}}"], directory) + expected = receipt(plan, "dependencies", plan["commit"], tree, plan["previous_head"]) + publish_pull(api, plan, expected, directory, environment, f"build(deps): backport dependencies for {plan['tag']}") + plan.update(status="voting", release_backport=expected) + cycle.save(plan) + expected = plan["release_backport"] + pull = api.request(f"repos/{repository}/pulls/{expected['number']}") + merged = merge_pull(api, expected, pull, require_tip="release_vote" not in plan) + if merged is None: + plan.update(status="voting", reason=f"Waiting for backport PR #{expected['number']} checks and required reviews") + cycle.save(plan) + return {**plan, "action": "waiting"} + expected["merge_commit_sha"] = merged + if repository.endswith("/notation-core-go"): + plan.update(status="ready", commit=expected["head_sha"], previous_head=merged, reason="") + cycle.save(plan) + return {**plan, "action": "qualify"} + from monthly_release_vote import assess_vote + path = "signer/signer.go" if repository.endswith("/notation-go") else "internal/version/version.go" + if "release_vote" not in plan: + release.git(["fetch", "--no-tags", "origin", merged], directory) + cycle.save(plan) + with tempfile.TemporaryDirectory(prefix="monthly-release-vote-") as temporary: + worktree = pathlib.Path(temporary) / "vote" + release.git(["worktree", "add", "--detach", str(worktree), merged], directory) + try: + original = (worktree / path).read_text(encoding="utf-8") + updated = version_metadata(repository, original, plan["tag"]) + if original == updated: + raise ValueError("Release version metadata is already present without this cycle's vote") + (worktree / path).write_text(updated, encoding="utf-8") + release.git(["add", "--", path], worktree) + release.git(["commit", "-S", "-m", f"bump: tag and release {plan['tag']}"], worktree, environment) + head = release.git(["rev-parse", "HEAD"], worktree) + tree = release.git(["rev-parse", "HEAD^{tree}"], worktree) + vote = { + **receipt(plan, "vote", head, tree, merged), + "candidate_sha": merged, "candidate_tree": expected["head_tree"], + "maintainer_roster_sha": plan["maintainer_roster_sha"], + "metadata_before": original, "metadata_after": updated, + "signer_login": os.environ["MONTHLY_PATCH_SIGNER_LOGIN"], + "merge_committer_login": "web-flow", + } + publish_pull(api, plan, vote, worktree, environment, f"bump: tag and release {plan['tag']}") + plan["release_vote"] = vote + cycle.save(plan) + finally: + release.git(["worktree", "remove", str(worktree)], directory) + vote = plan["release_vote"] + pull = api.request(f"repos/{repository}/pulls/{vote['number']}") + reviews = list(api.pages(f"repos/{repository}/pulls/{vote['number']}/reviews")) + roster = content(api, repository, "MAINTAINERS", vote["maintainer_roster_sha"]) + compare = api.request(f"repos/{repository}/compare/{vote['candidate_sha']}...{vote['head_sha']}") + if compare.get("status") != "ahead" or compare.get("ahead_by") != 1 or compare.get("behind_by") != 0: + raise ValueError("Release vote must contain exactly one metadata commit") + diff = { + "files": [{"filename": item["filename"], "status": item["status"]} for item in compare.get("files", [])], + "before": content(api, repository, path, vote["candidate_sha"]), + "after": content(api, repository, path, vote["head_sha"]), + } + result = assess_vote(vote, pull, reviews, roster, diff) + if result["status"] != "ready": + plan.update(status="voting", reason=result.get("reason", "Waiting for repo-maintainer release votes")) + cycle.save(plan) + return {**plan, "action": "waiting"} + final = merge_pull(api, vote, pull) + if final is None: + plan.update(status="voting", reason=f"Release vote PR #{vote['number']} still needs ordinary CI or branch reviews") + cycle.save(plan) + return {**plan, "action": "waiting"} + vote["merge_commit_sha"] = final + from monthly_release_vote import qualify_release_commit + vote["receipt"] = qualify_release_commit(vote, api.request(f"repos/{repository}/pulls/{vote['number']}"), + api.request(f"repos/{repository}/commits/{final}")) + plan.update(status="ready", commit=vote["head_sha"], previous_head=final, reason="") + validate(api, plan) + cycle.save(plan) + return {**plan, "action": "qualify"} + + +def validate(api, plan): + expected = plan.get("release_backport") + if not expected: + raise ValueError("Canonical publication requires a checked release backport PR") + pull = api.request(f"repos/{plan['repository']}/pulls/{expected['number']}") + pull_identity(pull, expected) + if not pull.get("merged_at") or pull.get("merge_commit_sha") != expected.get("merge_commit_sha"): + raise ValueError("Release backport merge provenance changed") + if plan["repository"].endswith("/notation-core-go"): + final = merge_pull(api, expected, pull) + if plan["commit"] != expected["head_sha"] or plan["previous_head"] != final: + raise ValueError("Core publication does not match its qualified dependency PR") + return expected + from monthly_release_vote import validate_vote + return validate_vote(api, plan) diff --git a/.github/scripts/monthly_release_vote.py b/.github/scripts/monthly_release_vote.py new file mode 100644 index 000000000..18f55bf0a --- /dev/null +++ b/.github/scripts/monthly_release_vote.py @@ -0,0 +1,302 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Read-only release-vote qualification. Callers own PR creation and merging.""" + +import base64 +import datetime +import re +from urllib.parse import quote + + +class VoteError(ValueError): + """Release identity, scope, or provenance does not match the pinned plan.""" + + +def _require(condition, message): + if not condition: + raise VoteError(message) + + +def _login(value): + _require(isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9-]+(?:\[bot\])?", value), + "invalid GitHub login") + return value.lower() + + +def parse_repo_maintainers(text): + """Parse only the active repo-level section of the trusted MAINTAINERS file.""" + section = False + found = False + logins = [] + for line in text.splitlines(): + if line.startswith("# Repo-Level Maintainers"): + _require(not found, "duplicate repo-level maintainer section") + section = found = True + elif section and line.startswith("# ") and not line.startswith("# Note:"): + section = False + elif section and line.strip() and not line.startswith("#"): + match = re.fullmatch(r".+ <[^<>]+> \(@([A-Za-z0-9-]+)\)", line.strip()) + _require(match is not None, "malformed repo-level maintainer entry") + logins.append(_login(match.group(1))) + _require(found and logins, "no active repo-level maintainer roster") + _require(len(set(logins)) == len(logins), "duplicate repo-level maintainer") + return sorted(logins) + + +def _identity(expected, pr): + for key in ("base_sha", "head_sha", "head_tree", "candidate_sha", "candidate_tree"): + _require(re.fullmatch(r"[0-9a-f]{40}", expected.get(key, "")), + f"invalid expected {key}") + _require(re.fullmatch(r"notaryproject/(notation|notation-go|notation-core-go)", + expected.get("repository", "")), "noncanonical repository") + if expected["repository"] == "notaryproject/notation-core-go": + _require(expected.get("vote_scope") == "dependency-candidate", + "Core release vote policy must declare dependency-candidate scope") + _require(expected["head_sha"] == expected["candidate_sha"] and + expected["head_tree"] == expected["candidate_tree"], + "Core vote changed the qualified dependency candidate") + else: + _require(expected["head_sha"] != expected["candidate_sha"] and + expected["head_tree"] != expected["candidate_tree"], + "release vote has no distinct metadata commit") + _require(re.fullmatch(r"release-\d+\.\d+", expected.get("base_ref", "")), + "invalid stable release branch") + _require(re.fullmatch(r"v\d+\.\d+\.\d+", expected.get("tag", "")), + "invalid stable patch tag") + _require(expected["base_ref"] == "release-" + expected["tag"][1:].rsplit(".", 1)[0], + "release tag and branch differ") + actor = _login(expected["actor"]) + _require(isinstance(expected.get("number"), int) and expected["number"] > 0, + "missing pinned release PR number") + _require(bool(expected.get("head_ref")), "missing pinned release PR branch") + _require(pr.get("number") == expected["number"], "release PR number changed") + _require(_login(pr.get("user", {}).get("login")) == actor, "release PR actor changed") + for side in ("base", "head"): + actual = pr.get(side, {}) + _require(actual.get("repo", {}).get("full_name") == expected["repository"], + f"release PR {side} repository changed") + _require(actual.get("sha") == expected[f"{side}_sha"], + f"release PR {side} commit changed") + _require(actual.get("ref") == expected[f"{side}_ref"], + f"release PR {side} branch changed") + body = pr.get("body") or "" + root = "https://github.com/" + expected["repository"] + _require(root + "/blob/" + expected["base_sha"] + "/MAINTAINERS" in body, + "release PR lacks pinned maintainer-list link") + _require(root + "/compare/" in body, "release PR lacks changelog link") + + +def qualify_release_vote(expected, pr, reviews, maintainer_logins): + """Return ready/waiting evidence for the immutable, current-head release vote. + + Expected pins repository, number, actor, base_ref/base_sha, head_ref/head_sha, + head_tree, candidate_sha/candidate_tree, and tag. Callers must derive the + roster from the assessed default-branch commit, not actor-provided login lists. + This result is not CI, source-scope, or vulnerability certification. + """ + _identity(expected, pr) + roster = {_login(login) for login in maintainer_logins} + _require(roster and len(roster) == len(maintainer_logins), "invalid maintainer roster") + latest = {} + seen = set() + for review in reviews: + login = _login(review.get("user", {}).get("login")) + if login not in roster: + continue + ident = review.get("id") + _require(isinstance(ident, int) and ident > 0 and ident not in seen, + "invalid or duplicate review ID") + seen.add(ident) + submitted = review.get("submitted_at") + _require(isinstance(submitted, str), "review has no submission time") + try: + timestamp = datetime.datetime.fromisoformat(submitted.replace("Z", "+00:00")) + except ValueError as error: + raise VoteError("invalid review submission time") from error + _require(timestamp.tzinfo is not None, "review timestamp has no timezone") + state = review.get("state") + _require(state in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED", "COMMENTED"}, + "invalid submitted review state") + # Comments do not revoke a prior approval or a changes-requested review. + if state == "COMMENTED": + continue + if login not in latest or (timestamp, ident) > latest[login][0]: + latest[login] = ((timestamp, ident), review) + approvals = sorted(login for login, (_, review) in latest.items() + if review["state"] == "APPROVED" + and review.get("commit_id") == expected["head_sha"]) + blocking = sorted(login for login, (_, review) in latest.items() + if review["state"] == "CHANGES_REQUESTED") + required = len(roster) // 2 + 1 + evidence = {"approvals": approvals, "required": required, "maintainers": sorted(roster), + "blocking": blocking, "head_sha": expected["head_sha"], + "number": expected["number"]} + _require(pr.get("state") in {"open", "closed"}, "invalid release PR state") + _require(pr["state"] != "closed" or bool(pr.get("merged_at")), + "release PR closed without merge") + reason = ("release PR remains draft" if pr.get("draft") else + "maintainer changes requested" if blocking else + "current-head maintainer majority absent" if len(approvals) < required else "") + return {**evidence, "status": "waiting" if reason else "ready", + "reason": reason or "current-head maintainer majority satisfied"} + + +def _signed(commit, signer): + verification = commit.get("commit", {}).get("verification", {}) + _require(verification.get("verified") is True and verification.get("reason") == "valid", + "release commit signature is not verified") + _require(_login(commit.get("committer", {}).get("login")) == _login(signer), + "release commit committer provenance changed") + + +def qualify_release_commit(expected, pr, commit): + """Qualify the exact normal merge commit, never a squash/rebase or admin bypass.""" + _identity(expected, pr) + _require(pr.get("state") == "closed" and bool(pr.get("merged_at")), + "release voting PR is not merged") + sha = commit.get("sha") + _require(re.fullmatch(r"[0-9a-f]{40}", sha or ""), "invalid final commit") + _require(sha == pr.get("merge_commit_sha"), "final commit is not release PR merge") + parents = [parent.get("sha") for parent in commit.get("parents", [])] + _require(parents == [expected["base_sha"], expected["head_sha"]], + "release PR did not use the exact normal merge parents") + tree = commit.get("commit", {}).get("tree", {}).get("sha") + _require(tree == expected["head_tree"], "merge tree differs from voted tree") + _require(_login(expected["merge_committer_login"]) in + {_login(expected["actor"]), "web-flow"}, + "merge committer is neither configured actor nor GitHub normal merger") + _signed(commit, expected["merge_committer_login"]) + return {"sha": expected["head_sha"], "merge_commit_sha": sha, + "tree": tree, "number": expected["number"], + "head_sha": expected["head_sha"], "base_sha": expected["base_sha"], + "repository": expected["repository"], "tag": expected["tag"], + "committer_login": expected["merge_committer_login"]} + + +def _metadata(expected, release_diff): + """Allow only the verified upstream version fields, not arbitrary source edits.""" + repo = expected["repository"] + if repo == "notaryproject/notation-core-go": + _require(expected.get("vote_scope") == "dependency-candidate" + and expected["head_sha"] == expected["candidate_sha"] + and expected["head_tree"] == expected["candidate_tree"], + "Core vote changed the qualified dependency candidate") + _require(release_diff.get("files") == [] and + release_diff.get("before") is None and release_diff.get("after") is None, + "Core vote must not add metadata or unexplained source changes") + return + path = {"notaryproject/notation-go": "signer/signer.go", + "notaryproject/notation": "internal/version/version.go"}.get(repo) + _require(path is not None, "repository release metadata policy is not defined") + _require(release_diff.get("files") == [{"filename": path, "status": "modified"}], + "release vote changes files outside version metadata") + before, after = release_diff.get("before"), release_diff.get("after") + _require(isinstance(before, str) and isinstance(after, str), + "missing release metadata contents") + _require(before == expected["metadata_before"] and after == expected["metadata_after"], + "release metadata differs from pinned plan") + if repo.endswith("/notation-go"): + pattern = r'(?m)^const signingAgent = "notation-go/[^"\n]+"$' + _require(len(re.findall(pattern, before)) == 1, "ambiguous signingAgent metadata") + wanted = re.sub(pattern, 'const signingAgent = "notation-go/' + + expected["tag"][1:] + '"', before) + else: + wanted = before + for field, value in (("Version", expected["tag"]), ("BuildMetadata", "")): + pattern = rf'(?m)^([ \t]*){field} = "[^"\n]*"$' + _require(len(re.findall(pattern, wanted)) == 1, f"ambiguous {field} metadata") + wanted = re.sub(pattern, lambda match: match.group(1) + field + ' = "' + + value + '"', wanted) + _require(before != after and after == wanted, "release vote is not metadata-only") + + +def assess_vote(expected, pr, reviews, roster_text, release_diff): + """Qualify votes after verifying roster and exact metadata-only release scope.""" + _metadata(expected, release_diff) + return qualify_release_vote(expected, pr, reviews, parse_repo_maintainers(roster_text)) + + +def _contents(api, repository, path, ref): + result = api.request(f"repos/{repository}/contents/{path}?ref={quote(ref, safe='')}") + _require(result.get("encoding") == "base64" and result.get("type") == "file", + "unsupported repository content encoding") + try: + return base64.b64decode("".join(result["content"].split()), validate=True).decode("utf-8") + except (ValueError, UnicodeError, KeyError) as error: + raise VoteError("invalid repository file contents") from error + + +def validate_vote(api, plan): + """Re-read all release evidence before qualification, tagging, or publishing. + + api.request(path) returns REST JSON; api.pages(path) returns all list entries. + plan.release_vote contains the expected pins plus an immutable receipt. + Maintainer roster is read at plan.maintainer_roster_sha, the assessed default + branch commit. Callers bind that commit to the trusted automation digest and + require reassessment when the roster changes. + The caller must enforce required checks/reviews through normal branch rules. + """ + expected = plan["release_vote"] + repo = expected["repository"] + _require(plan["repository"] == repo and plan["tag"] == expected["tag"], + "plan and release vote identity differ") + _require(re.fullmatch(r"[0-9a-f]{40}", expected.get("maintainer_roster_sha", "")) and + expected["maintainer_roster_sha"] == plan.get("maintainer_roster_sha"), + "missing trusted maintainer roster commit") + pr = api.request(f"repos/{repo}/pulls/{expected['number']}") + reviews = list(api.pages(f"repos/{repo}/pulls/{expected['number']}/reviews")) + roster = _contents(api, repo, "MAINTAINERS", expected["maintainer_roster_sha"]) + compare = api.request(f"repos/{repo}/compare/{expected['candidate_sha']}..." + f"{expected['head_sha']}") + core = repo == "notaryproject/notation-core-go" + _require(compare.get("status") == ("identical" if core else "ahead") and + compare.get("ahead_by") == (0 if core else 1) and compare.get("behind_by") == 0, + "release vote candidate scope changed") + candidate = api.request(f"repos/{repo}/commits/{expected['candidate_sha']}") + head = api.request(f"repos/{repo}/commits/{expected['head_sha']}") + _require(candidate.get("sha") == expected["candidate_sha"] + and candidate.get("commit", {}).get("tree", {}).get("sha") == + expected["candidate_tree"], "dependency candidate tree changed") + if expected["candidate_sha"] != expected["base_sha"]: + _require([item.get("sha") for item in candidate.get("parents", [])] == + [expected["base_sha"]], "qualified candidate parent changed") + _signed(candidate, expected["signer_login"]) + _require(head.get("sha") == expected["head_sha"] + and head.get("commit", {}).get("tree", {}).get("sha") == expected["head_tree"], + "voted head tree changed") + _require([item.get("sha") for item in head.get("parents", [])] == + [expected["base_sha"] if core else expected["candidate_sha"]], + "voted commit parent changed") + _signed(head, expected["signer_login"]) + message = head.get("commit", {}).get("message", "").splitlines() + _require(message and re.fullmatch(r"[a-z][a-z0-9-]*(?:\([^)]+\))?!?: .+", message[0]), + "release voting commit lacks a conventional commit message") + path = ("signer/signer.go" if repo.endswith("/notation-go") + else "internal/version/version.go") + diff = {"files": [{"filename": item["filename"], "status": item["status"]} + for item in compare.get("files", [])], + "before": None if core else _contents(api, repo, path, expected["candidate_sha"]), + "after": None if core else _contents(api, repo, path, expected["head_sha"])} + vote = assess_vote(expected, pr, reviews, roster, diff) + _require(vote["status"] == "ready", vote["reason"]) + commit = api.request(f"repos/{repo}/commits/{pr['merge_commit_sha']}") + receipt = qualify_release_commit(expected, pr, commit) + _require(receipt == expected["receipt"], "immutable release vote receipt changed") + _require(plan["commit"] == receipt["sha"] and + plan["previous_head"] == receipt["merge_commit_sha"], + "plan does not reference voted head and final merge tip") + branch = api.request(f"repos/{repo}/git/ref/heads/{quote(expected['base_ref'], safe='/')}") + _require(branch.get("object", {}).get("sha") == receipt["merge_commit_sha"], + "release branch moved after voting") + return receipt diff --git a/.github/scripts/notation_release_controller.py b/.github/scripts/notation_release_controller.py new file mode 100644 index 000000000..244ae8a44 --- /dev/null +++ b/.github/scripts/notation_release_controller.py @@ -0,0 +1,1784 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validate read-only assessments and authorize the ordered release DAG.""" + +import argparse +import base64 +import copy +import datetime +import hashlib +import html +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile +import urllib.parse + +import monthly_release as release +from monthly_release_checks import scan_messages + +OWNER = "notaryproject" +HOST = f"{OWNER}/notation" +MODE = "execute" +REPOSITORIES = tuple(f"{OWNER}/{name}" for name in release.PROJECTS) +POLICY_PATH = ".github/monthly-patch-policy.yml" +AGENT_PATH = ".github/workflows/notation-release-agent.lock.yml" +WORKER_PATH = ".github/workflows/monthly-patch-release.yml" +SHARED_PATH = ".github/workflows/monthly-patch-worker.yml" +DIGEST = re.compile(r"[0-9a-f]{64}$") +DECISIONS = {"release", "skip", "defer"} +NODE_STATUSES = {"pending", "dispatching", "running", "failed", "published", "skipped", "deferred"} +TERMINAL = {"published", "skipped"} + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate JSON field: {key}") + result[key] = value + return result + + +def load_json(text): + return json.loads(text, object_pairs_hook=unique_object) + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def utc_now(): + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def positive_id(value): + if type(value) is not int or value <= 0: + raise ValueError("Expected a positive integer identifier") + return value + + +def repository_metadata(api, repository): + if repository not in REPOSITORIES: + raise ValueError("Coordinator repository scope is fixed") + metadata = api.request(f"repos/{repository}") + if metadata.get("fork") or metadata.get("private") or metadata.get("default_branch") != "main": + raise ValueError(f"Expected a public canonical repository with trusted default main: {repository}") + return metadata + + +def repository_content(api, repository, path, commit): + item = api.optional(f"repos/{repository}/contents/{path}?ref={commit}") + if item is None: + return None + if item.get("encoding") != "base64": + raise ValueError(f"Cannot inspect trusted worker installation: {path}") + return base64.b64decode(item["content"], validate=False).decode("utf-8") + + +def worker_binding(api, repository, commit): + """Bind the dispatch caller to its literal, immutable central implementation.""" + text = repository_content(api, repository, WORKER_PATH, commit) + if text is None: + return None + sections = re.findall(r"(?m)^jobs:\s*\n([\s\S]*)", text) + if len(sections) != 1: + raise ValueError("Missing or duplicate caller jobs") + section = sections[0] + if re.findall(r"(?m)^ ([A-Za-z_][A-Za-z0-9_-]*):", section) != ["release"]: + raise ValueError("Release caller must contain only the release reusable job") + references = re.findall(r"(?m)^ uses: ([^\n#]+)$", section) + pins = re.findall(r"(?m)^ worker_sha: ([^\n#]+)$", section) + if len(references) != 1 or len(pins) != 1 or re.search(r"(?m)^\s*(?:<<:|[&*!])", text): + raise ValueError("Ambiguous caller shared worker binding") + reference, pin = references[0].strip(), pins[0].strip() + if repository == HOST: + if reference != "./" + SHARED_PATH or pin != "${{ github.sha }}": + raise ValueError("CLI caller must use the same-commit relative shared worker") + sha = commit + else: + match = re.fullmatch(re.escape(HOST + "/" + SHARED_PATH + "@") + r"([0-9a-f]{40})", reference or "") + if match is None or pin != match[1]: + raise ValueError("Library caller must bind uses and worker_sha to the same full central SHA") + sha = pin + if not release.SHA.fullmatch(sha): + raise ValueError("Invalid shared worker revision") + return {"worker_repository": HOST, "worker_path": SHARED_PATH, "worker_sha": sha, + "worker_digest": automation_digest(api, HOST, sha)} + + +def installed_worker(api, repository, commit, binding=None): + binding = binding or worker_binding(api, repository, commit) + if binding is None: + return False + files = { + SHARED_PATH: ("MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'", " workflow_call:"), + ".github/scripts/notation_release_controller.py": ("def worker_authorization(",), + ".github/scripts/monthly_release.py": ("coordinator_authorization(",), + ".github/scripts/monthly_release_pr.py": ("def advance(", "def validate("), + ".github/scripts/monthly_release_vote.py": ("def validate_vote(",), + ".github/scripts/qualify_monthly_candidate.py": ("def qualify(",), + ".github/scripts/build_monthly_assets.py": ("def ",), + ".github/scripts/test_monthly_public_release.py": ("def ",), + ".github/scripts/release_issue_topology.py": ("def scope_body(", "def ensure("), + ".github/scripts/release_bugfix_proposals.py": ("def collect(", "def validate_proposals("), + } + for path, required in files.items(): + content = repository_content(api, HOST, path, binding["worker_sha"]) + if content is None: + return False + if any(token not in content for token in required): + return False + return True + + +def validate_worker_binding(snapshot): + if (snapshot.get("worker_repository") != HOST or snapshot.get("worker_path") != SHARED_PATH + or not release.SHA.fullmatch(snapshot.get("worker_sha", "")) + or not DIGEST.fullmatch(snapshot.get("worker_digest", ""))): + raise ValueError("Missing or invalid central shared worker identity") + + +def trusted_shared_revision(api, binding): + main = api.request(f"repos/{HOST}/branches/main")["commit"]["sha"] + comparison = api.request(f"repos/{HOST}/compare/{binding['worker_sha']}...{main}") + if comparison.get("status") not in {"identical", "ahead"} or comparison.get("behind_by") != 0: + raise ValueError("Shared worker revision is not reachable from reviewed central main") + if binding["worker_digest"] != automation_digest(api, HOST, main): + raise ValueError("Central worker code changed; explicit reassessment is required") + + +def observed_worker(api, repository, run_id, sha): + run = api.request(f"repos/{repository}/actions/runs/{positive_id(run_id)}") + references = [item for item in run.get("referenced_workflows", []) + if item.get("path", "").split("@", 1)[0] == HOST + "/" + SHARED_PATH] + if (len(references) != 1 or references[0].get("sha") != sha + or references[0].get("path") != HOST + "/" + SHARED_PATH + "@" + sha): + raise ValueError("Observed run does not invoke the authorized shared worker revision") + return run + + +def automation_digest(api, repository, commit): + tree = api.request(f"repos/{repository}/git/trees/{commit}?recursive=1") + if tree.get("truncated") or not isinstance(tree.get("tree"), list): + raise ValueError("Incomplete automation tree") + entries = { + item["path"]: {"sha": item["sha"], "mode": item["mode"], "type": item["type"]} + for item in tree["tree"] + if item["path"].startswith((".github/scripts/", ".github/workflows/", ".github/aw/")) + or item["path"] in {POLICY_PATH, ".github/monthly-goreleaser.yml", ".github/licenserc.yml", ".gitattributes", + "MAINTAINERS", "RELEASE_CHECKLIST.md"} + } + if WORKER_PATH not in entries or any(item["type"] != "blob" or item["mode"] not in {"100644", "100755"} for item in entries.values()): + raise ValueError("Missing or unsafe automation files") + return digest(entries) + + +def source_checkpoint(api, repository, snapshot, month): + current = api.request(f"repos/{repository}/branches/{snapshot['main']}")["commit"]["sha"] + if current == snapshot["main_head"]: + return current + candidate = release.Cycle(api, repository, month, MODE).state + if candidate is None: + raise ValueError("Source changed without a trusted worker cycle") + authorized = {item["commit"] for item in candidate.get("merged", [])} + ahead = compare_commits(api, repository, snapshot["main_head"], current) + if not ahead or not ahead <= authorized: + raise ValueError("Source advanced outside recorded worker dependency merges") + authorization = {"snapshot": snapshot, "producers": candidate.get("producers", {})} + for item in candidate.get("merged", []): + if item["commit"] not in ahead: + continue + pull = api.request(f"repos/{repository}/pulls/{item['number']}") + if not release.dependabot(pull, snapshot["main"]) or not scoped_pull(api, repository, pull, authorization, candidate["merged"]): + raise ValueError("Source checkpoint contains an untrusted dependency merge") + release.check_files(api, repository, item["number"]) + return current + + +def assessed_automation(api, repository, snapshot, commit): + if automation_digest(api, repository, commit) != snapshot.get("automation_digest"): + raise ValueError("Release worker code changed after assessment; explicit reassessment is required") + return commit + + +def assessed_worker(api, repository, snapshot, caller_sha): + binding = worker_binding(api, repository, caller_sha) + if binding is None or any(binding[key] != snapshot.get(key) for key in binding): + # A CLI source dependency merge may advance its commit without changing automation. + if not (repository == HOST and binding is not None + and binding["worker_digest"] == snapshot.get("worker_digest") + and binding["worker_repository"] == snapshot.get("worker_repository") + and binding["worker_path"] == snapshot.get("worker_path")): + raise ValueError("Shared worker pin changed; explicit reassessment is required") + return binding + + +def load_policy(api, commit): + item = api.request(f"repos/{HOST}/contents/{POLICY_PATH}?ref={commit}") + if item.get("encoding") != "base64": + raise ValueError("Missing maintainer-owned release policy") + policy = load_json(base64.b64decode(item["content"]).decode()) + expected = { + "schema": 1, "repositories": list(REPOSITORIES), + "dependency_versions": "same-major-minor-nondecreasing", + "producer_versions": "exact-planned-stable-patch", + "go_requirement": "unchanged-for-routine-updates", "reachable_vulnerabilities": "block", + "optional_backports": "excluded", "workflow_updates": "explicit-approval", + "maximum_assessment_age_hours": 24, + "plan_approval": "active-cli-maintainer-comment", + "publication": "maintainer-publishes-qualified-draft", + "release_votes": {"notation-core-go": "branch-rules", "notation-go": "repo-maintainer-majority", + "notation": "repo-maintainer-majority"}, + } + if policy != expected or type(policy.get("schema")) is not int or type(policy.get("maximum_assessment_age_hours")) is not int: + raise ValueError("Unsupported monthly-release policy; reviewed implementation is required") + return policy + + +def routine_pull(api, repository, pull): + if not release.dependabot(pull, "main") or (pull["head"].get("repo") or {}).get("full_name") != repository: + raise ValueError("Automatic authority is limited to same-repository Dependabot PRs") + release.check_files(api, repository, pull["number"]) + paths = {f"{directory}/go.{extension}".removeprefix("./") + for directory in release.modules(repository) for extension in ("mod", "sum")} + if any(item["filename"] not in paths for item in api.pages(f"repos/{repository}/pulls/{pull['number']}/files")): + raise ValueError("Workflow updates require explicit actor approval and reassessment") + for directory in release.modules(repository): + before = api.manifest(repository, pull["base"]["sha"], directory) + after = api.manifest(repository, pull["head"]["sha"], directory) + if before.get("Replace") or after.get("Replace"): + raise ValueError("Routine updates cannot contain replacements") + for field in set(before) | set(after): + if field != "Require" and before.get(field) != after.get(field): + raise ValueError("Routine updates cannot change module or Go requirements") + original = {item["Path"]: item for item in before.get("Require") or []} + updated = {item["Path"]: item for item in after.get("Require") or []} + if original.keys() != updated.keys(): + raise ValueError("Added or removed dependencies require explicit actor approval") + for path, previous in original.items(): + following = updated[path] + old, new = release.semver(previous["Version"]), release.semver(following["Version"]) + if (old is None or new is None or old[:2] != new[:2] or new < old + or previous.get("Indirect") != following.get("Indirect")): + raise ValueError("Routine dependency versions must remain on the same stable major/minor") + + +def review_scope(api, plan): + commit = api.request(f"repos/{HOST}/branches/main")["commit"]["sha"] + policy = load_policy(api, commit) + age = datetime.datetime.now(datetime.timezone.utc) - datetime.datetime.fromisoformat(plan["collected_at"]) + if age < datetime.timedelta(0) or age > datetime.timedelta(hours=policy["maximum_assessment_age_hours"]): + raise ValueError("Automatic assessment is stale") + for repository, decision in plan["decisions"].items(): + snapshot = plan["snapshots"][repository] + if decision["decision"] == "defer": + raise ValueError("Deferrals require maintainer resolution") + if any(result["reachable"] for result in snapshot["scans"].values()): + raise ValueError("Reachable findings require resolution and a new assessment") + for item in snapshot["pulls"] + snapshot["merged"]: + pull = api.request(f"repos/{repository}/pulls/{item['number']}") + if "head" in item and pull["head"]["sha"] != item["head"]: + raise ValueError("Assessed PR head changed") + if "commit" in item and pull.get("merge_commit_sha") != item["commit"]: + raise ValueError("Assessed merge changed") + routine_pull(api, repository, pull) + return {"kind": "pending", "policy_sha256": digest(policy), "policy_commit": commit} + + +def authorize_routine(api, plan): + raise ValueError("Automatic cycle authorization is disabled; a maintainer must approve the plan issue") + + +def plan_comment(plan): + return "/approve" + + +def safe_title(value): + text = html.escape(" ".join(str(value).split())[:400], quote=True) + return re.sub(r"[\\`*_{}\[\]()#+.!|@]", lambda match: f"&#{ord(match[0])};", text) + + +def release_scope(plan): + lines = ["## Release scope", "", + "| Repository | Baseline to proposed patch | Decision | Planned producer requirement |", + "|---|---|---|---|"] + for repository in REPOSITORIES: + snapshot = plan["snapshots"][repository] + producers = [] + for name in release.PROJECTS[repository.split("/")[1]]: + source = f"{OWNER}/{name}" + producer = plan["snapshots"][source] + decision = plan["decisions"][source]["decision"] + producers.append(f"`{name}` exactly `{producer['tag']}` after maintainer publication and public verification" + if decision == "release" else f"`{name}` has no new authorized version ({decision})") + decision = "Proposed, not qualified" if plan.get("preview") else plan["decisions"][repository]["decision"] + lines.append(f"| `{repository}` | `{snapshot['baseline']}` to `{snapshot['tag']}` | " + f"{decision} | {'; '.join(producers) or 'None'} |") + lines += ["", "### Dependabot source commits", "", + "| Repository | Dependabot PR/change | Exact source revision(s) | CI/review status |", + "|---|---|---|---|"] + count = 0 + for repository in REPOSITORIES: + snapshot = plan["snapshots"][repository] + for field, revision, label in (("pulls", "head", "Open PR proposed HEAD (not yet merged)"), + ("merged", "commit", "Already merged source commit to backport")): + for item in snapshot[field]: + number = positive_id(item["number"]) + sha = item[revision] + if not release.SHA.fullmatch(sha): + raise ValueError("Invalid scope commit identity") + title = safe_title(item.get("title", "Dependabot dependency update")) + revisions = [f"{label}: [`{sha}`](https://github.com/{repository}/commit/{sha})"] + for commit in item.get("commits", []): + if not release.SHA.fullmatch(commit["sha"]): + raise ValueError("Invalid proposed PR commit") + if commit["sha"] != sha: + revisions.append(f"Additional proposed commit: [`{commit['sha']}`]" + f"(https://github.com/{repository}/commit/{commit['sha']}) {safe_title(commit.get('subject', ''))}") + lines.append(f"| `{repository}` | [#{number}](https://github.com/{repository}/pull/{number}) {title} | " + f"{'
'.join(revisions)} | {safe_title(item.get('review_status', 'Required checks and reviews must pass'))} |") + count += 1 + if not count: + lines.append("| All repositories | No existing Dependabot source PRs in this assessment | No source commit invented | Not qualified |") + lines += ["", "Future producer-adoption Dependabot PRs, merge commits, and stable-branch backport commits " + "do not exist yet and are not listed as known commits. Workers wait for exact planned producer versions " + "and qualifying PRs. Signed Go/CLI version-metadata vote commits are separate release-governance " + "changes, not Dependabot source commits. Optional CVE/bug-fix backports remain outside automatic scope.", ""] + return "\n".join(lines) + + +def controller_body(state): + import release_issue_topology as threads + routes = state.get("release_issues") + scope = (threads.scope_body(state["plan"], HOST, routes) if routes else + "Release issue wiring is incomplete. Approval cannot authorize execution yet.\n") + instructions = ( + "An active CLI repo-level maintainer must review this scope and linked dependency issues, " + "then post a new, unedited approval for this plan revision:\n\n" + f"```text\n{plan_comment(state['plan'])}\n```\n\n" + f"Assessment: https://github.com/{HOST}/actions/runs/{state['source_run']}\n\n" + "Reassessment requires fresh approval. Editing/deleting approval revokes execution. " + "Publication remains manual after qualified draft review.\n\n" + ) + blocker = f"Execution blocker: {safe_title(state['review_blocker'])}\n\n" if state.get("review_blocker") else "" + overview = "" + if routes: + overview = "\n## Overall status\n\n| Release issue | Status |\n|---|---|\n" + for repository in REPOSITORIES: + overview += (f"| [{repository}#{routes[repository]}]" + f"({threads.issue_url(repository, routes[repository])}) | " + f"{state['nodes'][repository]['status']} |\n") + return (f"{marker(state['month'])}\n{instructions}{scope}\n{blocker}Outcome: **{state['status']}**.\n{overview}\n" + "
\nTechnical plan and controller state\n\n" + f"```json\n{json.dumps(state, indent=2)}\n```\n\n
\n") + + +def pull_scope(api, repository, number): + commits = [{"sha": item["sha"], "subject": item["commit"]["message"].split("\n")[0][:400]} + for item in api.pages(f"repos/{repository}/pulls/{number}/commits")] + if not commits or any(not release.SHA.fullmatch(item["sha"]) for item in commits): + raise ValueError("Incomplete or invalid proposed PR commit list") + from release_issue_topology import dependency_changes + file_records = list(api.pages(f"repos/{repository}/pulls/{number}/files")) + files = [item["filename"] for item in file_records] + return {"commits": commits, "review_status": "Workflow changes need explicit actor review; CI/reviews must pass" + if any(path.startswith(".github/workflows/") for path in files) else "CI and required reviews must pass", + "dependency_changes": dependency_changes(file_records)} + + +def preview_scope(api, output, inventory_path=None): + snapshots, decisions = {}, {} + captured = load_json(inventory_path.read_text())["repositories"] if inventory_path else None + for repository in REPOSITORIES: + if captured is not None: + node = captured[repository] + baseline = node["latest_stable"] + if isinstance(baseline, dict): + baseline = baseline["tag_name"] + version = release.semver(baseline) + if version is None: + raise ValueError("Preview requires a real stable baseline") + snapshots[repository] = { + "baseline": baseline, "tag": f"v{version[0]}.{version[1]}.{version[2] + 1}", + "main_head": node["main_head"], "pulls": [], "merged": [], + } + for p in node["pulls"]: + if p["author"] != "dependabot[bot]" or p["head_repository"] != repository or p["base"] != "main": + raise ValueError("Preview source is not a canonical Dependabot PR") + snapshots[repository]["pulls"].append({ + **{key: p[key] for key in ("number", "head", "title", "commits")}, + "review_status": "Workflow changes need explicit actor review; CI/reviews are not qualified" + if any(path.startswith(".github/workflows/") for path in p["files"]) + else "CI/reviews are not qualified in this format preview", + }) + decisions[repository] = {"decision": "release" if snapshots[repository]["pulls"] else "skip"} + continue + repository_metadata(api, repository) + baseline = release.baseline_plan(api, repository, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m"), MODE) + main = api.request(f"repos/{repository}/branches/main")["commit"]["sha"] + stable = api.request(f"repos/{repository}/branches/{baseline['branch']}")["commit"]["sha"] + ahead = compare_commits(api, repository, stable, main) + pulls = [{"number": p["number"], "head": p["head"]["sha"], "title": p.get("title", ""), + **pull_scope(api, repository, p["number"])} + for p in api.pulls(repository, "main", "open") if release.dependabot(p, "main")] + merged = [p for p in release.inventory(api, repository, "main") if p["commit"] in ahead] + for item in merged: + item["title"] = api.request(f"repos/{repository}/pulls/{item['number']}").get("title", "") + snapshots[repository] = {**baseline, "pulls": pulls, "merged": merged, "main_head": main, "release_head": stable} + decisions[repository] = {"decision": "release" if pulls or merged else "skip"} + for repository in REPOSITORIES: + if any(decisions[f"{OWNER}/{name}"]["decision"] == "release" for name in release.PROJECTS[repository.split("/")[1]]): + decisions[repository]["decision"] = "release" + data = {"snapshots": snapshots, "decisions": decisions, "preview": True} + (output / "preview-inventory.json").write_text(json.dumps(data, indent=2) + "\n") + revision = release.git(["rev-parse", "HEAD"], pathlib.Path(__file__).resolve().parents[2]) + (output / "plan-preview.md").write_text( + "# FORMAT PREVIEW ONLY: no release authorization\n\n" + f"Implementation revision: `{revision}`. Read-only live canonical Dependabot inventory; " + "no source qualification, security verdict, or execution approval is represented. " + "A copy of this preview in a fork cannot authorize canonical execution.\n\n" + release_scope(data)) + return {"writes": False, "preview": str(output / "plan-preview.md")} + + +def plan_roster(api, state): + current = api.request(f"repos/{HOST}/branches/main")["commit"]["sha"] + assessed_automation(api, HOST, state["plan"]["snapshots"][HOST], current) + text = repository_content(api, HOST, "MAINTAINERS", current) + if text is None: + raise ValueError("Missing active CLI maintainer roster") + from monthly_release_vote import parse_repo_maintainers + return parse_repo_maintainers(text) + + +def approval_comment(api, issue, state, receipt=None): + import release_issue_topology as threads + if not state.get("release_issues") or not state.get("review_started_at"): + raise ValueError("Release issue wiring and review revision must be complete before approval") + threads.validate_routes(HOST, state["release_issues"]) + for repository in REPOSITORIES: + if repository != HOST: + threads.checked_issue(api, os.environ.get("MONTHLY_PATCH_ACTOR"), repository, + state["release_issues"][repository], + threads.scope_body(state["plan"], repository, state["release_issues"])) + roster = plan_roster(api, state) + if (issue.get("state") != "open" or issue.get("body") != controller_body(state) + or issue.get("user", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR")): + raise ValueError("Plan review issue body, author or open state changed") + release.trusted_editor(api, HOST, issue) + comments = ([api.request(f"repos/{HOST}/issues/comments/{positive_id(receipt['comment_id'])}")] + if receipt else api.pages(f"repos/{HOST}/issues/{issue['number']}/comments")) + for comment in comments: + if (comment.get("body") != plan_comment(state["plan"]) + or positive_id(comment["id"]) <= state.get("review_after_comment_id", 0) + or comment.get("user", {}).get("type") != "User" + or comment["user"].get("login", "").lower() not in roster + or not comment.get("created_at") or comment.get("updated_at") != comment["created_at"] + or comment.get("issue_url") != f"https://api.github.com/repos/{HOST}/issues/{issue['number']}"): + if receipt: + raise ValueError("Maintainer plan approval was edited, revoked or is no longer eligible") + continue + if comment["created_at"] <= max(issue["created_at"], state["review_started_at"]): + if receipt: + raise ValueError("Plan approval predates the current review revision") + continue + expected = {"kind": "maintainer", "comment_id": positive_id(comment["id"]), + "login": comment["user"]["login"], "created_at": comment["created_at"], + "plan_id": state["plan"]["plan_id"], "review_started_at": state["review_started_at"], + "release_issues_sha256": digest(state["release_issues"]), + "policy_sha256": state["authorization"]["policy_sha256"], + "policy_commit": state["authorization"]["policy_commit"]} + if receipt is not None and receipt != expected: + raise ValueError("Maintainer plan approval receipt changed") + return expected + if receipt: + raise ValueError("Maintainer plan approval is missing") + return None + + +def require_plan_approval(api, issue, state): + if state["authorization"].get("kind") != "maintainer": + raise ValueError("Execution requires maintainer approval of the exact plan issue") + approval_comment(api, issue, state, state["authorization"]) + current = api.request(f"repos/{HOST}/branches/main")["commit"]["sha"] + if digest(load_policy(api, current)) != state["authorization"]["policy_sha256"]: + raise ValueError("Maintainer policy changed; explicit reassessment is required") + + +def explicit_actor(): + if os.environ.get("GITHUB_EVENT_NAME") != "workflow_dispatch" or ( + not os.environ.get("MONTHLY_PATCH_ACTOR") + or os.environ.get("GITHUB_ACTOR") != os.environ["MONTHLY_PATCH_ACTOR"] + ): + raise ValueError("Exceptional approval, retry and replan require the configured actor's manual dispatch") + + +def scan_source(repository, commit, directories, output): + """Scan an immutable source tree without executing repository tests or hooks.""" + output.mkdir(parents=True, exist_ok=True) + results = {} + with tempfile.TemporaryDirectory(prefix="notation-release-assessment-") as temporary: + tree = pathlib.Path(temporary) + release.git(["init", "--quiet"], tree) + release.git(["remote", "add", "origin", f"https://github.com/{repository}.git"], tree) + release.git(["fetch", "--quiet", "--depth=1", "origin", commit], tree) + release.git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], tree) + if release.git(["rev-parse", "HEAD"], tree) != commit: + raise ValueError("Assessment source does not match its immutable commit") + for index, directory in enumerate(directories): + scanned = subprocess.run( + ["govulncheck", "-format=json", "./..."], cwd=tree / directory, + capture_output=True, text=True, encoding="utf-8", + ) + (output / f"{index}.jsonstream").write_text(scanned.stdout, encoding="utf-8") + (output / f"{index}.stderr.txt").write_text(scanned.stderr, encoding="utf-8") + if scanned.returncode != 0: + raise ValueError(f"Assessment scanner failed for {repository}:{directory}: {scanned.stderr}") + reachable, informational, advisories = scan_messages(scanned.stdout, "source") + results[directory] = { + "reachable": sorted(reachable), "informational": sorted(informational), + "advisories": { + key: {"summary": item.get("summary", ""), "modified": item.get("modified"), + "aliases": item.get("aliases", []), + "fixed": sorted({event["fixed"] for affected in item.get("affected", []) + for ranges in affected.get("ranges", []) + for event in ranges.get("events", []) if "fixed" in event})} + for key, item in advisories.items() + }, + } + return results + + +def compare_commits(api, repository, base, head): + commits = [] + for page in range(1, 11): + result = api.request(f"repos/{repository}/compare/{base}...{head}?per_page=100&page={page}") + if result.get("status") not in {"ahead", "identical", "behind", "diverged"}: + raise ValueError("Cannot establish main/release divergence") + commits.extend(result["commits"]) + if len(commits) == result["ahead_by"]: + if any(not release.SHA.fullmatch(item.get("sha", "")) for item in commits): + raise ValueError("Invalid comparison commit") + return {item["sha"] for item in commits} + if len(result["commits"]) < 100: + raise ValueError("Incomplete main/release comparison") + raise ValueError("Assessment comparison exceeds 1,000 commits; manual reconciliation is required") + + +def collect(api, month, output): + if not release.MONTH.fullmatch(month): + raise ValueError("Invalid assessment month") + inventory = {"schema": 1, "host": HOST, "month": month, "collected_at": utc_now(), "repositories": {}} + for repository in REPOSITORIES: + metadata = repository_metadata(api, repository) + baseline = release.baseline_plan(api, repository, month, MODE) + automation = api.request(f"repos/{repository}/branches/main")["commit"]["sha"] + main = api.optional(f"repos/{repository}/branches/{baseline['main']}") + branch = api.optional(f"repos/{repository}/branches/{baseline['branch']}") + blockers = [] + if not metadata.get("has_issues"): + blockers.append("Enable repository issues before writing release state") + if main is None or branch is None: + blockers.append("Reviewed source/release branches are missing") + workflow = api.optional(f"repos/{repository}/actions/workflows/monthly-patch-release.yml") + binding = worker_binding(api, repository, automation) + if workflow is None or workflow.get("state") != "active" or not installed_worker(api, repository, automation, binding): + blockers.append("Install and enable the complete coordinator-aware release worker on main") + if binding is not None: + trusted_shared_revision(api, binding) + source = repository if branch else f"notaryproject/{repository.split('/')[1]}" + commit = branch["commit"]["sha"] if branch else api.request( + f"repos/{source}/commits/{baseline['baseline']}" + )["sha"] + if not release.SHA.fullmatch(automation) or not release.SHA.fullmatch(commit): + raise ValueError("Invalid source or automation commit") + source_ref = main["commit"]["sha"] if main else automation + provenance = {} + baseline_sha = api.request(f"repos/{repository}/commits/{baseline['baseline']}")["sha"] + provenance = { + "baseline_sha": baseline_sha, + "baseline_tag_object": release.verified_tag(api, repository, baseline["baseline"], baseline_sha), + } + pulls = [] + for pull in api.pulls(repository, baseline["main"] if main else "main", "open"): + if not release.dependabot(pull, baseline["main"] if main else "main"): + continue + release.check_files(api, repository, pull["number"]) + ready, reason = release.checks_ready(pull, api.checks(repository, pull["number"])) + pulls.append({"number": pull["number"], "head": pull["head"]["sha"], + "title": (pull.get("title") or "")[:400], "body": (pull.get("body") or "")[:1600], + **pull_scope(api, repository, pull["number"]), + "ready": ready, "reason": reason}) + if pulls[-1]["commits"][-1]["sha"] != pull["head"]["sha"]: + raise ValueError("Dependabot PR changed during commit inventory collection") + merged = [] + if main and branch: + ahead = compare_commits(api, repository, commit, source_ref) + records = list(api.pages(f"repos/{repository}/commits?sha={commit}")) + history = "\n".join(item["commit"]["message"] for item in records) + for item in release.inventory(api, repository, baseline["main"]): + if item["commit"] not in ahead: + continue + backports = [record["sha"] for record in records + if f"(cherry picked from commit {item['commit']})" in record["commit"]["message"]] + if backports: + if any(f"This reverts commit {backport}." in history for backport in backports): + blockers.append(f"Dependency backport #{item['number']} was reverted; manual review is required") + continue + release.check_files(api, repository, item["number"]) + item["title"] = api.request(f"repos/{repository}/pulls/{item['number']}").get("title", "")[:400] + item["dependency_changes"] = pull_scope(api, repository, item["number"])["dependency_changes"] + merged.append(item) + bugfix_evidence = None + if main and branch: + from release_bugfix_proposals import collect as collect_bugfixes + bugfix_evidence = collect_bugfixes(api, repository, source_ref, commit) + scans = scan_source(source, commit, release.modules(repository), output / repository.split("/")[1]) + manifests = {directory: api.manifest(source, commit, directory) for directory in release.modules(repository)} + inventory["repositories"][repository] = { + "baseline": baseline["baseline"], "main": baseline["main"], "branch": baseline["branch"], + "tag": baseline["tag"], "main_head": main["commit"]["sha"] if main else None, + "release_head": branch["commit"]["sha"] if branch else None, + "automation_sha": automation, "workflow_id": workflow["id"] if workflow else None, + **(binding or {key: "" for key in ("worker_repository", "worker_path", "worker_sha", "worker_digest")}), + **({"automation_digest": automation_digest(api, repository, automation)}), + **provenance, + "source_repository": source, "source_commit": commit, "blockers": blockers, + "pulls": pulls, "merged": merged, "manifests": manifests, "scans": scans, + "bugfix_evidence": bugfix_evidence, + "bugfix_evidence_sha256": digest(bugfix_evidence) if bugfix_evidence is not None else None, + } + return inventory + + +def evidence_ids(repository, node): + return {f"{repository}:baseline", f"{repository}:setup", f"{repository}:dependencies", + *(f"{repository}:pr:{item['number']}" for item in node["pulls"] + node["merged"]), + *(f"{repository}:scan:{directory}" for directory in node["scans"])} + + +def validate_inventory(inventory): + if type(inventory.get("schema")) is not int or inventory["schema"] != 1 or inventory.get("host") != HOST: + raise ValueError("Unexpected assessment schema or host") + if not release.MONTH.fullmatch(inventory.get("month", "")) or set(inventory.get("repositories", {})) != set(REPOSITORIES): + raise ValueError("Incomplete assessment inventory") + when = datetime.datetime.fromisoformat(inventory["collected_at"]) + if when.tzinfo is None: + raise ValueError("Assessment timestamp must be timezone-aware") + for repository, node in inventory["repositories"].items(): + bugfix = node.get("bugfix_evidence") + if bugfix is not None and ( + bugfix.get("repository") != repository or bugfix.get("main_head") != node["main_head"] + or bugfix.get("release_head") != node["release_head"] + or bugfix.get("schema") != 1 or bugfix.get("authority") != "proposal_only" + or bugfix.get("automatic_inclusion") is not False + ): + raise ValueError("Bug-fix evidence is not bound to the assessed source/release line") + if "bugfix_evidence_sha256" in node and node["bugfix_evidence_sha256"] != ( + digest(bugfix) if bugfix is not None else None + ): + raise ValueError("Bug-fix evidence digest changed") + release.validate_plan({ + "schema": 1, "repository": repository, "month": inventory["month"], "mode": MODE, + **{key: node[key] for key in ("baseline", "main", "branch", "tag")}, "status": "merging", + }) + for field in ("automation_sha", "source_commit"): + if not release.SHA.fullmatch(node.get(field, "")): + raise ValueError("Invalid inventory commit") + for field in ("main_head", "release_head"): + if node.get(field) is not None and not release.SHA.fullmatch(node[field]): + raise ValueError("Invalid branch snapshot") + if node["source_repository"] not in {repository, f"notaryproject/{repository.split('/')[1]}"}: + raise ValueError("Assessment source escapes its project") + if not isinstance(node["blockers"], list) or not all(isinstance(item, str) for item in node["blockers"]): + raise ValueError("Invalid setup evidence") + if set(node["manifests"]) != set(release.modules(repository)) or set(node["scans"]) != set(release.modules(repository)): + raise ValueError("Incomplete module or vulnerability evidence") + for result in node["scans"].values(): + for field in ("reachable", "informational"): + if not isinstance(result[field], list) or any(not re.fullmatch(r"GO-\d{4}-\d+", item) for item in result[field]): + raise ValueError("Invalid vulnerability evidence") + numbers = set() + for kind, field in (("pulls", "head"), ("merged", "commit")): + if not isinstance(node[kind], list): + raise ValueError("Invalid dependency inventory") + for item in node[kind]: + number = positive_id(item["number"]) + if number in numbers or not release.SHA.fullmatch(item.get(field, "")): + raise ValueError("Duplicate or invalid dependency provenance") + numbers.add(number) + if kind == "pulls": + commits = item.get("commits") + if (not isinstance(commits, list) or not commits or commits[-1].get("sha") != item["head"] + or any(not release.SHA.fullmatch(commit.get("sha", "")) for commit in commits) + or len({commit["sha"] for commit in commits}) != len(commits)): + raise ValueError("Missing, duplicate or mismatched proposed PR commit list") + if node["workflow_id"] is not None: + positive_id(node["workflow_id"]) + if not DIGEST.fullmatch(node.get('automation_digest', '')): + raise ValueError("Missing automation tree identity") + if not node["blockers"]: + validate_worker_binding(node) + if any((not release.SHA.fullmatch(node.get(key, '')) for key in ('baseline_sha', 'baseline_tag_object'))): + raise ValueError("Missing signed stable-baseline provenance") + return inventory + + +def validate_assessment(inventory, assessment): + validate_inventory(inventory) + required = {"schema", "month", "inventory_sha256", "decisions"} + if not required <= set(assessment) or set(assessment) - required - {"bugfix_proposals"}: + raise ValueError("Unexpected assessment fields") + if type(assessment["schema"]) is not int or assessment["schema"] != 1 or assessment["month"] != inventory["month"] or assessment["inventory_sha256"] != digest(inventory): + raise ValueError("Assessment is not bound to the collected inventory") + if not isinstance(assessment["decisions"], dict) or set(assessment["decisions"]) != set(REPOSITORIES): + raise ValueError("Agent must assess all three repositories exactly once") + all_evidence = set().union(*(evidence_ids(repo, node) for repo, node in inventory["repositories"].items())) + for repository, item in assessment["decisions"].items(): + if set(item) != {"decision", "reason", "evidence"} or item["decision"] not in DECISIONS: + raise ValueError("Invalid release decision") + if not isinstance(item["reason"], str) or not 1 <= len(item["reason"]) <= 2000: + raise ValueError("Every decision needs a bounded rationale") + if not isinstance(item["evidence"], list) or not 1 <= len(item["evidence"]) <= 30 or any( + not isinstance(reference, str) or reference not in all_evidence for reference in item["evidence"] + ): + raise ValueError("Decision cites missing or fabricated evidence") + if not set(item["evidence"]) & evidence_ids(repository, inventory["repositories"][repository]): + raise ValueError("Decision needs evidence from its own repository") + node = inventory["repositories"][repository] + signals = bool(node["pulls"] or node["merged"] or any(result["reachable"] for result in node["scans"].values())) + if item["decision"] == "skip" and (signals or node["blockers"]): + raise ValueError("Known updates, CVEs or missing evidence cannot be skipped") + if item["decision"] == "release" and node["blockers"]: + raise ValueError("Repository setup is incomplete; defer rather than publish") + decisions = copy.deepcopy(assessment["decisions"]) + affected = set() + for repository in REPOSITORIES: + node = inventory["repositories"][repository] + upstream = [f"{OWNER}/{name}" for name in release.PROJECTS[repository.split("/")[1]]] + required = any(producer in affected for producer in upstream) + if required and decisions[repository]["decision"] == "skip": + raise ValueError("A planned library update requires downstream inclusion") + if decisions[repository]["decision"] == "release": + if not (node["pulls"] or node["merged"] or required): + raise ValueError("A CVE without a dependency fix requires deferral, not an empty patch") + affected.add(repository) + elif decisions[repository]["decision"] == "defer" and ( + node["pulls"] or node["merged"] or required or any(result["reachable"] for result in node["scans"].values()) + ): + affected.add(repository) + plan = { + "schema": 1, "host": HOST, "month": inventory["month"], "inventory_sha256": digest(inventory), + "collected_at": inventory["collected_at"], "decisions": decisions, + "snapshots": { + repo: {key: node[key] for key in ("baseline", "main", "branch", "tag", "main_head", + "release_head", "automation_sha", "workflow_id", "pulls", "merged", + "automation_digest", "scans", "baseline_sha", "baseline_tag_object", + "worker_repository", "worker_path", "worker_sha", "worker_digest")} + for repo, node in inventory["repositories"].items() + }, + } + has_bugfix_evidence = any(node.get("bugfix_evidence") is not None for node in inventory["repositories"].values()) + if has_bugfix_evidence or "bugfix_proposals" in assessment: + proposals = assessment.get("bugfix_proposals") + if not isinstance(proposals, dict) or set(proposals) != set(REPOSITORIES): + raise ValueError("Assessment must review bug-fix evidence for all three repositories") + from release_bugfix_proposals import validate_proposals + plan["bugfix_proposals"] = {} + for repository, node in inventory["repositories"].items(): + evidence = node.get("bugfix_evidence") + if evidence is None: + if proposals[repository] is not None: + raise ValueError("Bug-fix proposal has no source/release evidence") + continue + validated = validate_proposals(evidence, proposals[repository]) + if ({item["candidate_id"] for item in validated["proposals"]} + != {item["candidate_id"] for item in evidence["candidates"]}): + raise ValueError("Assessment must classify every observed new main commit") + plan["bugfix_proposals"][repository] = validated + # PR text helps the agent assess compatibility but is not executable authorization. + for node in plan["snapshots"].values(): + node["pulls"] = [{"number": item["number"], "head": item["head"], + "title": item.get("title", "Dependabot dependency update"), + "dependency_changes": item.get("dependency_changes", []), + "commits": item.get("commits", []), "review_status": item.get("review_status", "CI/reviews required")} + for item in node["pulls"]] + plan["plan_id"] = digest(plan) + return plan + + +def marker(month): + if not release.MONTH.fullmatch(month): + raise ValueError("Invalid controller month") + return f"" + + +def validate_state(state): + if type(state.get("schema")) is not int or state["schema"] != 1 or state.get("host") != HOST or state.get("mode") != MODE: + raise ValueError("Invalid controller state or execution namespace") + marker(state["month"]) + plan = state["plan"] + if not DIGEST.fullmatch(plan.get("plan_id", "")) or digest({key: value for key, value in plan.items() if key != "plan_id"}) != plan["plan_id"]: + raise ValueError("Approved plan changed") + if plan.get("host") != HOST or type(plan.get("schema")) is not int or plan["schema"] != 1 or plan.get("month") != state["month"]: + raise ValueError("Approved plan identity changed") + if set(plan["decisions"]) != set(REPOSITORIES) or set(plan["snapshots"]) != set(REPOSITORIES) or set(state["nodes"]) != set(REPOSITORIES): + raise ValueError("Controller repository scope changed") + if state.get("status") not in {"review", "active", "completed"}: + raise ValueError("Invalid controller status") + authorization = state.get("authorization", {}) + if authorization.get("kind") not in {"pending", "maintainer"}: + raise ValueError("Missing authorization provenance") + if ( + not DIGEST.fullmatch(authorization.get("policy_sha256", "")) + or not release.SHA.fullmatch(authorization.get("policy_commit", "")) + ): + raise ValueError("Invalid policy authorization") + if (state["status"] == "review") != (authorization["kind"] == "pending"): + raise ValueError("Unreviewed plan cannot execute") + if authorization["kind"] == "maintainer": + positive_id(authorization.get("comment_id")) + if (authorization.get("plan_id") != plan["plan_id"] + or authorization.get("review_started_at") != state.get("review_started_at") + or authorization.get("release_issues_sha256") != digest(state.get("release_issues"))): + raise ValueError("Approval belongs to another plan review revision") + if "release_issues" in state: + from release_issue_topology import validate_routes + validate_routes(HOST, state["release_issues"]) + for repository, node in state["nodes"].items(): + decision = plan["decisions"][repository]["decision"] + if decision not in DECISIONS or node["status"] not in NODE_STATUSES or type(node["attempt"]) is not int or node["attempt"] < 0: + raise ValueError("Invalid controller node") + if decision == "skip" and node["status"] != "skipped" or decision == "defer" and node["status"] != "deferred": + raise ValueError("Agent decision changed during execution") + snapshot = plan["snapshots"][repository] + if decision == "release": + validate_worker_binding(snapshot) + recovery = snapshot.get("verification_recovery") + if recovery is not None and ( + not isinstance(recovery, dict) or set(recovery) != {"from_plan_id", "candidate_sha256"} + or not all(isinstance(value, str) and DIGEST.fullmatch(value) for value in recovery.values()) + or decision != "release" + ): + raise ValueError("Invalid verification-only recovery receipt") + release.validate_plan({"schema": 1, "repository": repository, "mode": MODE, "month": state["month"], + **{key: snapshot[key] for key in ("baseline", "main", "branch", "tag")}, "status": "merging"}) + if node.get("run_id") is not None: + positive_id(node["run_id"]) + if node["status"] == "published" and ( + node.get("version") != snapshot["tag"] or not release.SHA.fullmatch(node.get("commit", "")) + ): + raise ValueError("Published producer identity changed") + if state["status"] == "completed" and any(node["status"] not in TERMINAL for node in state["nodes"].values()): + raise ValueError("Controller completed before its repositories") + return state + + +def decode_issue(issue, actor): + if "pull_request" in issue or not actor or issue.get("user", {}).get("login") != actor: + return None + body = issue.get("body") or "" + if "" + body = ( + f"{marker}\n" + + " ".join(f"@{login}" for login in sorted(roster)) + + f"\n\nRelease cycle **{state['month']}** is blocked.\n\n" + + f"- Repository: `{repository}`\n" + + f"- Failed stage: {', '.join(stages) if stages else 'release worker'}\n" + + f"- Reason: {safe_title(reason)}\n" + + f"- Logs and retained evidence: https://github.com/{repository}/actions/runs/{run_id}/attempts/{attempt}\n" + + "- Blocked downstream work: " + (", ".join(f"`{target}`" for target in blocked) or "none") + "\n\n" + + "Review the linked logs and resolve the failed gate before requesting a retry. " + + "Do not approve past the failure or publish an unqualified draft. " + + "The approved scope has not changed." + ) + if state.get("release_issues"): + routes = state["release_issues"] + if repository != HOST: + threads.checked_issue(self.api, self.actor, repository, routes[repository], + threads.scope_body(state["plan"], repository, routes)) + identity = digest({"plan": state["plan"]["plan_id"], "repository": repository, + "run": run_id, "attempt": attempt, "stages": stages}) + comment = threads.comment_once(self.api, self.actor, repository, routes[repository], identity, body) + for target in REPOSITORIES: + if (repository.split("/")[1] in release.PROJECTS[target.split("/")[1]] + and state["nodes"][target]["status"] not in TERMINAL): + if target != HOST: + threads.checked_issue(self.api, self.actor, target, routes[target], + threads.scope_body(state["plan"], target, routes)) + threads.comment_once( + self.api, self.actor, target, routes[target], f"dependency-{identity}", + f"Blocked by [{repository}#{routes[repository]}]" + f"({threads.issue_url(repository, routes[repository])}). " + "Review the dependency's release issue for the blocker.", + ) + return comment + matches = [ + comment for comment in self.api.pages(f"repos/{HOST}/issues/{issue['number']}/comments") + if comment.get("user", {}).get("login") == self.actor and marker in (comment.get("body") or "") + ] + if len(matches) > 1: + raise ValueError("Duplicate release failure notifications require reconciliation") + if matches: + return matches[0] + comment = self.api.request(f"repos/{HOST}/issues/{issue['number']}/comments", "POST", {"body": body}) + if (comment.get("user", {}).get("login") != self.actor + or comment.get("body") != body or type(comment.get("id")) is not int): + raise ValueError("Release failure notification was not confirmed") + positive_id(comment["id"]) + return comment + + def report_blocker(self, issue, state, repository, run, reason): + import release_issue_topology as threads + routes = state.get("release_issues") + if not routes or repository not in REPOSITORIES or not isinstance(reason, str) or not 1 <= len(reason) <= 2000: + raise ValueError("Invalid release blocker notification") + current = self.api.request(f"repos/{HOST}/issues/{issue['number']}") + if current.get("body") != controller_body(state): + raise ValueError("Release issue changed before blocker notification") + release.trusted_editor(self.api, HOST, current) + if repository != HOST: + threads.checked_issue(self.api, self.actor, repository, routes[repository], + threads.scope_body(state["plan"], repository, routes)) + main = self.api.request(f"repos/{repository}/branches/main")["commit"]["sha"] + from monthly_release_vote import parse_repo_maintainers + roster = parse_repo_maintainers(repository_content(self.api, repository, "MAINTAINERS", main)) + identity = "blocker-" + digest({"plan": state["plan"]["plan_id"], "repository": repository, + "reason": reason, "run": positive_id(run["id"])}) + comment = threads.comment_once( + self.api, self.actor, repository, routes[repository], identity, + " ".join(f"@{login}" for login in sorted(roster)) + "\n\n" + f"Release blocked: {safe_title(reason)}\n\n" + f"Evidence: https://github.com/{repository}/actions/runs/{positive_id(run['id'])}\n\n" + "Resolve the required check or review before resuming. This notice does not waive a gate or change scope.", + ) + for target in REPOSITORIES: + if repository.split("/")[1] in release.PROJECTS[target.split("/")[1]]: + if target != HOST: + threads.checked_issue(self.api, self.actor, target, routes[target], + threads.scope_body(state["plan"], target, routes)) + threads.comment_once(self.api, self.actor, target, routes[target], "dependency-" + identity, + f"Blocked by [{repository}#{routes[repository]}]" + f"({threads.issue_url(repository, routes[repository])}).") + return comment + + def save(self, issue, state): + validate_state(state) + import release_issue_topology as threads + if issue: + current = self.api.request(f"repos/{HOST}/issues/{issue['number']}") + previous = decode_issue(current, self.actor) + if previous is None or current.get("body") != controller_body(previous): + raise ValueError("Controller issue changed before state write") + release.trusted_editor(self.api, HOST, current) + had_routes = bool(state.get("release_issues")) + routes, hashes = threads.ensure(self.api, self.actor, state["plan"], issue["number"], + state.get("release_issues"), state.get("release_issue_hashes")) + state.update(release_issues=routes, release_issue_hashes=hashes) + if not had_routes and state["status"] == "review": + state["review_started_at"] = utc_now() + state["review_after_comment_id"] = max( + (positive_id(item["id"]) for item in self.api.pages(f"repos/{HOST}/issues/{issue['number']}/comments")), + default=0, + ) + body = controller_body(state) + if len(body.encode()) > 60000: + raise ValueError("Controller record is too large") + payload = {"body": body, "state": "closed" if state["status"] == "completed" else "open"} + if issue: + result = self.api.request(f"repos/{HOST}/issues/{issue['number']}", "PATCH", payload) + else: + payload["title"] = f"Notation dependency release cycle: {state['month']}" + result = self.api.request(f"repos/{HOST}/issues", "POST", payload) + if decode_issue(result, self.actor) != state: + raise ValueError("Controller state write was not confirmed by the trusted actor") + if not issue: + return self.save(result, state) + return result + + def approve(self, plan, source_run, replan=False, recover_public_verification=False, authorization=None): + if recover_public_verification and not replan: + raise ValueError("Public verification recovery requires an explicit replan") + records = self.records() + for issue, state in records: + if state["month"] == plan["month"]: + if state["plan"]["plan_id"] != plan["plan_id"]: + if not replan: + raise ValueError("This month already has an immutable approved plan") + return self.replan(issue, state, plan, source_run, recover_public_verification) + if recover_public_verification: + raise ValueError("Verification recovery requires a fresh changed assessment") + return self.save(issue, state), state + if state["status"] in {"active", "review"}: + raise ValueError("Finish the active cycle before approving another month") + if recover_public_verification: + raise ValueError("Verification recovery requires an existing approved cycle") + when = datetime.datetime.fromisoformat(plan["collected_at"]) + age = datetime.datetime.now(datetime.timezone.utc) - when + if age < datetime.timedelta(0) or age > datetime.timedelta(days=7): + raise ValueError("Assessment is stale; collect a fresh assessment before approval") + for repository, item in plan["decisions"].items(): + if item["decision"] != "release": + continue + metadata = repository_metadata(self.api, repository) + snapshot = plan["snapshots"][repository] + if not metadata.get("has_issues"): + raise ValueError("Worker cycle issues must be enabled before approval") + for name, expected in ((snapshot["main"], snapshot["main_head"]), + (snapshot["branch"], snapshot["release_head"]), + ("main", snapshot["automation_sha"])): + if self.api.request(f"repos/{repository}/branches/{name}")["commit"]["sha"] != expected: + raise ValueError("Assessed refs changed before approval") + state = { + "schema": 1, "host": HOST, "mode": MODE, "month": plan["month"], + "plan": copy.deepcopy(plan), "source_run": positive_id(source_run), "status": "review", + "authorization": {"kind": "pending", "policy_commit": plan["snapshots"][HOST]["automation_sha"], + "policy_sha256": digest(load_policy(self.api, plan["snapshots"][HOST]["automation_sha"]))}, + "nodes": {repo: {"status": {"release": "pending", "skip": "skipped", "defer": "deferred"}[item["decision"]], + "attempt": 0} + for repo, item in plan["decisions"].items()}, + } + state["review_started_at"] = utc_now() + state["review_after_comment_id"] = 0 + if authorization and authorization.get("blocker"): + state["review_blocker"] = authorization["blocker"] + return self.save(None, state), state + + def replan(self, issue, state, plan, source_run, recover_public_verification=False): + if state["status"] not in {"active", "review"} or any(node["status"] in {"dispatching", "running"} for node in state["nodes"].values()): + raise ValueError("Cannot revise a completed cycle or an in-flight worker") + when = datetime.datetime.fromisoformat(plan["collected_at"]) + age = datetime.datetime.now(datetime.timezone.utc) - when + if age < datetime.timedelta(0) or age > datetime.timedelta(days=7): + raise ValueError("Replacement assessment is stale") + updated = copy.deepcopy(plan) + recovered = False + for repository, node in state["nodes"].items(): + previous = state["plan"]["snapshots"][repository] + if node["status"] in TERMINAL: + if updated["decisions"][repository]["decision"] != "skip": + raise ValueError("Reassessment cannot release an already completed repository again") + updated["decisions"][repository] = state["plan"]["decisions"][repository] + updated["snapshots"][repository] = previous + continue + if self.api.optional(f"repos/{repository}/git/ref/tags/{previous['tag']}") is not None: + if not recover_public_verification: + raise ValueError("An immutable public candidate must be recovered, not replanned") + cycle = release.Cycle(self.api, repository, state["month"], MODE) + candidate = cycle.state + if (candidate is None or candidate["status"] not in {"draft", "awaiting-publication", "verifying"} + or candidate.get("controller_issue") != issue["number"] + or candidate.get("controller_plan_id") != state["plan"]["plan_id"] + or any(candidate.get(key) != previous[key] for key in ("baseline", "main", "branch", "tag"))): + raise ValueError("Recovery requires this approved cycle's exact public verification candidate") + snapshot = updated["snapshots"][repository] + branch_tip = candidate["previous_head"] if candidate.get("release_backport") else candidate["commit"] + if (any(snapshot[key] != previous[key] for key in ("baseline", "main", "branch", "workflow_id")) + or snapshot["release_head"] != branch_tip): + raise ValueError("Recovery cannot change the public candidate or its release branch") + for name, expected in ((snapshot["main"], snapshot["main_head"]), (snapshot["branch"], branch_tip), + ("main", snapshot["automation_sha"])): + if self.api.request(f"repos/{repository}/branches/{name}")["commit"]["sha"] != expected: + raise ValueError("Verification recovery assessment refs are stale") + release.assert_tag(self.api, candidate) + if candidate["status"] == "verifying": + release.check_public_assets(self.api, candidate) + else: + release.check_release_assets(self.api, candidate, True) + updated["decisions"][repository] = state["plan"]["decisions"][repository] + updated["snapshots"][repository] = { + **previous, "automation_sha": snapshot["automation_sha"], "release_head": branch_tip, + "automation_digest": snapshot["automation_digest"], "main_head": snapshot["main_head"], + **{key: snapshot[key] for key in ("worker_repository", "worker_path", "worker_sha", "worker_digest")}, + "verification_recovery": { + "from_plan_id": state["plan"]["plan_id"], + "candidate_sha256": digest({key: candidate.get(key) for key in ("tag", "commit", "assets", "release_id")}), + }, + } + recovered = True + continue + if updated["decisions"][repository]["decision"] != state["plan"]["decisions"][repository]["decision"]: + raise ValueError("Reassessment may refresh refs, not silently change the release DAG") + snapshot = updated["snapshots"][repository] + if any(snapshot[key] != previous[key] for key in ("baseline", "main", "branch", "tag", "release_head")): + raise ValueError("Reassessment changed the reserved patch or release baseline") + for name, expected in ((snapshot["main"], snapshot["main_head"]), (snapshot["branch"], snapshot["release_head"]), + ("main", snapshot["automation_sha"])): + if self.api.request(f"repos/{repository}/branches/{name}")["commit"]["sha"] != expected: + raise ValueError("Replacement assessment refs are stale") + if recover_public_verification and not recovered: + raise ValueError("Verification recovery requires an existing immutable public candidate") + previous_id = state["plan"]["plan_id"] + updated["supersedes"] = previous_id + updated["plan_id"] = digest({key: value for key, value in updated.items() if key != "plan_id"}) + revisions = state.setdefault("revisions", []) + if len(revisions) >= 20: + raise ValueError("Cycle revision limit reached") + revisions.append({"from": previous_id, "to": updated["plan_id"], "source_run": positive_id(source_run)}) + for node in state["nodes"].values(): + if node["status"] not in TERMINAL: + node["status"] = "pending" + node.pop("run_id", None) + state.update(plan=updated, source_run=source_run) + state["review_started_at"] = utc_now() + state["review_after_comment_id"] = max( + (positive_id(item["id"]) for item in self.api.pages(f"repos/{HOST}/issues/{issue['number']}/comments")), + default=0, + ) + state.pop("review_blocker", None) + state["status"] = "review" + state["authorization"] = {"kind": "pending", "policy_commit": updated["snapshots"][HOST]["automation_sha"], + "policy_sha256": digest(load_policy(self.api, updated["snapshots"][HOST]["automation_sha"]))} + return self.save(issue, state), state + + def accept_review(self, issue, state): + validate_state(state) + if state["status"] == "completed": + return issue, state + current_issue = self.api.request(f"repos/{HOST}/issues/{issue['number']}") + if decode_issue(current_issue, self.actor) != state: + raise ValueError("Plan issue changed before execution") + if state["status"] == "review": + receipt = approval_comment(self.api, current_issue, state) + if receipt is None: + return current_issue, state + if any(item["decision"] == "defer" for item in state["plan"]["decisions"].values()): + raise ValueError("Resolve deferrals and reassess before approving a release cycle") + if state.get("review_blocker"): + raise ValueError(f"Plan execution is blocked: {state['review_blocker']}") + current = self.api.request(f"repos/{HOST}/branches/main")["commit"]["sha"] + policy = load_policy(self.api, current) + if digest(policy) != state["authorization"]["policy_sha256"]: + raise ValueError("Maintainer policy changed; reassess before approval") + age = datetime.datetime.now(datetime.timezone.utc) - datetime.datetime.fromisoformat(state["plan"]["collected_at"]) + if age < datetime.timedelta(0) or age > datetime.timedelta(hours=policy["maximum_assessment_age_hours"]): + raise ValueError("Assessment is stale; reassess and approve the new exact plan") + for repository, decision in state["plan"]["decisions"].items(): + if decision["decision"] == "release": + snapshot = state["plan"]["snapshots"][repository] + caller = self.api.request(f"repos/{repository}/branches/main")["commit"]["sha"] + assessed_automation(self.api, repository, snapshot, caller) + trusted_shared_revision(self.api, assessed_worker(self.api, repository, snapshot, caller)) + if self.api.request(f"repos/{repository}/branches/{snapshot['branch']}")["commit"]["sha"] != snapshot["release_head"]: + raise ValueError("Stable branch changed before maintainer approval") + source_checkpoint(self.api, repository, snapshot, state["month"]) + for item in snapshot["pulls"]: + if self.api.request(f"repos/{repository}/pulls/{item['number']}")["head"]["sha"] != item["head"]: + raise ValueError("Assessed PR head changed before maintainer approval") + state.update(status="active", authorization=receipt) + issue = self.save(current_issue, state) + return issue, state + + def advance(self, issue, state, retry_failed=False): + validate_state(state) + if state["status"] == "completed": + return issue, state + issue, state = self.accept_review(issue, state) + if state["status"] == "review": + return issue, state + require_plan_approval(self.api, issue, state) + for repository in REPOSITORIES: + node = state["nodes"][repository] + if node["status"] in TERMINAL: + continue + producers = [f"{OWNER}/{name}" for name in release.PROJECTS[repository.split("/")[1]]] + if node["status"] == "deferred" or any(state["nodes"][producer]["status"] not in TERMINAL for producer in producers): + return issue, state + snapshot = state["plan"]["snapshots"][repository] + if node["status"] == "failed": + if not retry_failed: + run = {"id": node["run_id"], "run_attempt": node.get("failure_run_attempt", 1)} + self.report_failure( + issue, state, repository, run, node["reason"], node.get("failure_stages", ()), + ) + return issue, state + node.pop("run_id", None) + node["status"] = "pending" + if node["status"] == "pending": + metadata = repository_metadata(self.api, repository) + dispatch_sha = assessed_automation( + self.api, repository, snapshot, + self.api.request(f"repos/{repository}/branches/{metadata['default_branch']}")["commit"]["sha"], + ) + binding = assessed_worker(self.api, repository, snapshot, dispatch_sha) + trusted_shared_revision(self.api, binding) + source_checkpoint(self.api, repository, snapshot, state["month"]) + worker = self.api.request(f"repos/{repository}/actions/workflows/monthly-patch-release.yml") + if worker["id"] != snapshot["workflow_id"] or worker["state"] != "active": + raise ValueError("Assessed release worker changed or is disabled") + node.update(status="dispatching", attempt=node["attempt"] + 1, dispatched_at=utc_now(), + dispatch_sha=dispatch_sha, worker_sha=binding["worker_sha"]) + issue = self.save(issue, state) + self.api.request(f"repos/{repository}/actions/workflows/{worker['id']}/dispatches", "POST", { + "ref": "main", "inputs": { + "mode": MODE, "month": state["month"], "controller_issue": str(issue["number"]), + "plan_id": state["plan"]["plan_id"], "attempt": str(node["attempt"]), + }, + }) + return issue, state + if node["status"] == "dispatching": + candidates = [] + query = urllib.parse.urlencode({"event": "workflow_dispatch", "created": ">=" + node["dispatched_at"]}) + for page in range(1, 11): + runs = self.api.request( + f"repos/{repository}/actions/workflows/{snapshot['workflow_id']}/runs?{query}&per_page=100&page={page}" + )["workflow_runs"] + candidates.extend(run for run in runs if run.get("display_title") == run_title(state["month"], state["plan"]["plan_id"], issue["number"], node["attempt"])) + if len(runs) < 100: + break + else: + raise ValueError("Incomplete dispatched-run discovery") + if len(candidates) > 1: + raise ValueError("Duplicate dispatched workers require reconciliation") + if not candidates: + # Do not re-dispatch an ambiguous request after a runner/network failure. + return issue, state + run = candidates[0] + if run.get("actor", {}).get("login") != self.actor or run.get("head_sha") != node.get("dispatch_sha", snapshot["automation_sha"]) or run.get("event") != "workflow_dispatch": + raise ValueError("Worker provenance does not match the approved dispatch") + node.update(status="running", run_id=positive_id(run["id"])) + issue = self.save(issue, state) + run = self.api.request(f"repos/{repository}/actions/runs/{node['run_id']}") + if (run.get("actor", {}).get("login") != self.actor or run.get("workflow_id") != snapshot["workflow_id"] + or run.get("head_sha") != node.get("dispatch_sha", snapshot["automation_sha"]) or run.get("event") != "workflow_dispatch" + or run.get("display_title") != run_title(state["month"], state["plan"]["plan_id"], issue["number"], node["attempt"])): + raise ValueError("Recorded worker identity changed") + observed_worker(self.api, repository, run["id"], node["worker_sha"]) + cycle = release.Cycle(self.api, repository, state["month"], MODE) + final = cycle.state is not None and cycle.state["status"] in release.FINAL + if run['status'] != 'completed': + return issue, state + if run['status'] == 'completed' and run['conclusion'] != 'success': + details = cycle.state.get("failure_details", {}) if cycle.state else {} + stages = details.get("stages", ()) if details.get("run_id") == run["id"] else () + node.update(status="failed", reason=f"Worker {node['run_id']} concluded {run['conclusion']}", + failure_stages=list(stages), failure_run_attempt=positive_id(run.get("run_attempt", 1))) + issue = self.save(issue, state) + self.report_failure(issue, state, repository, run, node["reason"], stages) + return issue, state + if cycle.state is None: + # A blocked setup must never look like a successfully skipped patch. + node.update(status="failed", reason="Worker returned without a trusted release cycle", + failure_stages=["prepare"], failure_run_attempt=positive_id(run.get("run_attempt", 1))) + issue = self.save(issue, state) + self.report_failure(issue, state, repository, run, node["reason"], node["failure_stages"]) + return issue, state + worker_plan = cycle.state + if worker_plan.get("controller_plan_id") != state["plan"]["plan_id"] or worker_plan.get("controller_issue") != issue["number"]: + raise ValueError("Worker cycle belongs to a different approved plan") + if worker_plan["status"] not in release.FINAL: + node.update(status="pending", reason=worker_plan.get("reason", "Worker is waiting for dependency checks")) + node.pop("run_id", None) + issue = self.save(issue, state) + if worker_plan["status"] in {"waiting", "voting"} and worker_plan.get("reason"): + self.report_blocker(issue, state, repository, run, worker_plan["reason"]) + return issue, state + if worker_plan["tag"] != snapshot["tag"]: + raise ValueError("Worker changed its approved patch version") + if worker_plan["status"] == "published": + release.assert_tag(self.api, worker_plan) + release.check_public_assets(self.api, worker_plan) + expected = {producer.split("/")[1]: {"repository": producer, "version": state["nodes"][producer]["version"]} + for producer in producers if state["nodes"][producer]["status"] == "published"} + if release.producer_lag(self.api, repository, worker_plan["commit"], MODE, expected): + raise ValueError("Published consumer does not include its planned producer versions") + node.update(status="published", version=worker_plan["tag"], commit=worker_plan["commit"]) + else: + if any(state["nodes"][producer]["status"] == "published" for producer in producers): + raise ValueError("A required consumer patch cannot be skipped after its producer released") + node["status"] = "skipped" + issue = self.save(issue, state) + state["status"] = "completed" + return self.save(issue, state), state + + +def run_title(month, plan_id, issue, attempt): + return f"Monthly patch {month} {plan_id} #{issue} attempt {attempt}" + + +def worker_authorization(api, repository, month, plan=None, allow_previous=False, notification=False): + if notification and plan is not None: + raise ValueError("Controller progress notifications cannot authorize writing stages") + if os.environ.get("MONTHLY_PATCH_COORDINATOR_REQUIRED") != "true": + raise ValueError("Canonical workers always require a coordinator receipt") + issue_number = os.environ.get("MONTHLY_PATCH_CONTROLLER_ISSUE", "") + plan_id = os.environ.get("MONTHLY_PATCH_CONTROLLER_PLAN", "") + attempt = os.environ.get("MONTHLY_PATCH_CONTROLLER_ATTEMPT", "") + if not issue_number.isdecimal() or not attempt.isdecimal() or int(issue_number) <= 0 or int(attempt) <= 0 or not DIGEST.fullmatch(plan_id): + raise ValueError("Writing workers require an approved coordinator receipt") + repository_metadata(api, repository) + issue = api.request(f"repos/{HOST}/issues/{issue_number}") + release.trusted_editor(api, HOST, issue) + state = decode_issue(issue, os.environ.get("MONTHLY_PATCH_ACTOR")) + if state is None or state["month"] != month or state["status"] != "active" or state["plan"]["plan_id"] != plan_id: + raise ValueError("Untrusted, stale or completed coordinator authorization") + require_plan_approval(api, issue, state) + node = state["nodes"][repository] + if state["plan"]["decisions"][repository]["decision"] != "release" or node["status"] not in {"dispatching", "running"} or node["attempt"] != int(attempt): + raise ValueError("Coordinator did not dispatch this worker attempt") + snapshot = state["plan"]["snapshots"][repository] + if os.environ.get("GITHUB_SHA") != node.get("dispatch_sha", snapshot["automation_sha"]) or os.environ.get("GITHUB_REF") != "refs/heads/main": + raise ValueError("Writing worker must use the assessed default-branch code") + if release.verified_tag(api, repository, snapshot["baseline"], snapshot["baseline_sha"]) != snapshot["baseline_tag_object"]: + raise ValueError("Signed stable baseline changed") + assessed_automation(api, repository, snapshot, os.environ["GITHUB_SHA"]) + binding = assessed_worker(api, repository, snapshot, os.environ["GITHUB_SHA"]) + if (binding["worker_sha"] != node.get("worker_sha") + or os.environ.get("MONTHLY_PATCH_WORKER_SHA") != node["worker_sha"]): + raise ValueError("Worker input revision does not match the dispatched shared implementation") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not run_id.isdecimal(): + raise ValueError("Writing worker requires its observed run identity") + observed = observed_worker(api, repository, int(run_id), binding["worker_sha"]) + if (observed.get("actor", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR") + or observed.get("head_sha") != node["dispatch_sha"] + or observed.get("workflow_id") != snapshot["workflow_id"] + or observed.get("event") != "workflow_dispatch" + or observed.get("display_title") != run_title(month, plan_id, int(issue_number), int(attempt))): + raise ValueError("Current worker run does not match the issued dispatch receipt") + current = api.request(f"repos/{repository}/branches/main")["commit"]["sha"] + assessed_automation(api, repository, snapshot, current) + assessed_worker(api, repository, snapshot, current) + trusted_shared_revision(api, binding) + source_checkpoint(api, repository, snapshot, month) + recovery = snapshot.get("verification_recovery") + if recovery is not None: + candidate = release.Cycle(api, repository, month, MODE).state + if (candidate is None or ( + candidate["status"] not in {"draft", "awaiting-publication", "verifying"} + and not (notification and candidate["status"] == "published")) + or candidate.get("controller_issue") != int(issue_number) + or candidate.get("controller_plan_id") not in {plan_id, recovery["from_plan_id"]} + or candidate["tag"] != snapshot["tag"] + or digest({key: candidate.get(key) for key in ("tag", "commit", "assets", "release_id")}) != recovery["candidate_sha256"]): + raise ValueError("Verification recovery cannot change or rebuild its immutable public candidate") + release.assert_tag(api, candidate) + if candidate["status"] in {"draft", "awaiting-publication"}: + release.check_release_assets(api, candidate, True) + else: + release.check_public_assets(api, candidate) + producers = {} + for name in release.PROJECTS[repository.split("/")[1]]: + source = f"{OWNER}/{name}" + upstream = state["nodes"][source] + if upstream["status"] not in TERMINAL: + raise ValueError("Producer verification has not completed") + if upstream["status"] == "published": + producer_snapshot = state["plan"]["snapshots"][source] + producer_run = api.request(f"repos/{source}/actions/runs/{positive_id(upstream.get('run_id'))}") + observed_worker(api, source, producer_run["id"], upstream["worker_sha"]) + if (producer_run.get("actor", {}).get("login") != os.environ.get("MONTHLY_PATCH_ACTOR") + or producer_run.get("workflow_id") != producer_snapshot["workflow_id"] + or producer_run.get("head_sha") != upstream.get("dispatch_sha") + or producer_run.get("event") != "workflow_dispatch" + or producer_run.get("conclusion") != "success" + or producer_run.get("display_title") != run_title(month, plan_id, int(issue_number), upstream["attempt"])): + raise ValueError("Producer publication has no trusted successful worker provenance") + candidate = release.Cycle(api, source, month, MODE).state + if (not candidate or candidate["status"] != "published" + or candidate.get("controller_issue") != int(issue_number) + or candidate.get("controller_plan_id") != plan_id + or candidate["tag"] != upstream["version"] or candidate["commit"] != upstream["commit"]): + raise ValueError("Producer cycle does not match the approved publication") + release.assert_tag(api, candidate) + release.check_public_assets(api, candidate) + producers[name] = {"repository": source, "version": upstream["version"]} + if plan is not None: + if recovery is not None and ( + plan.get("status") not in {"draft", "awaiting-publication", "verifying"} + or digest({key: plan.get(key) for key in ("tag", "commit", "assets", "release_id")}) != recovery["candidate_sha256"] + ): + raise ValueError("A recovered public candidate authorizes verification only") + allowed_ids = {plan_id} + if allow_previous and plan.get("status") in {"merging", "waiting", "voting", "ready"}: + allowed_ids.update(item["from"] for item in state.get("revisions", [])) + if allow_previous and recovery is not None and plan.get("status") in {"draft", "awaiting-publication", "verifying"}: + if digest({key: plan.get(key) for key in ("tag", "commit", "assets", "release_id")}) == recovery["candidate_sha256"]: + allowed_ids.add(recovery["from_plan_id"]) + if (plan.get("mode") != MODE or plan.get("cycle_id", "monthly") != "monthly" + or plan.get("controller_issue") != int(issue_number) or plan.get("controller_plan_id") not in allowed_ids + or any(plan.get(key) != snapshot[key] for key in ("baseline", "main", "branch", "tag")) + or plan.get("producers", {}) != producers): + raise ValueError("Worker plan escapes its fixed coordinator authorization") + if plan.get('status') != 'verifying' and plan.get('controller_plan_id') == plan_id: + if plan.get("maintainer_roster_sha") != snapshot["automation_sha"]: + raise ValueError("Worker maintainer roster does not match the assessed main") + return {"issue": int(issue_number), "plan_id": plan_id, "snapshot": snapshot, "producers": producers, + "release_issue": state.get("release_issues", {}).get(repository)} + + +def module_version(value): + match = re.fullmatch( + r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", + value if isinstance(value, str) else "", + ) + if match is None: + raise ValueError("Invalid producer module version") + major, minor, patch, prerelease = match.groups() + identifiers = [] + for identifier in prerelease.split(".") if prerelease is not None else (): + if identifier.isdecimal(): + if len(identifier) > 1 and identifier.startswith("0"): + raise ValueError("Invalid numeric producer prerelease identifier") + identifiers.append((0, int(identifier))) + else: + identifiers.append((1, identifier)) + return int(major), int(minor), int(patch), prerelease is None, tuple(identifiers) + + +def producer_manifests(before_modules, after_modules, producers, producer_modules, current): + changed = False + allowed = {f"github.com/notaryproject/{name}" for name in producers} + def go_version(value): + if not isinstance(value, str) or not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", value): + raise ValueError("Invalid producer Go requirement") + return tuple(map(int, value.split("."))) + (0,) * (3 - len(value.split("."))) + producer_go = {name: go_version(manifest["Go"]) for name, manifest in producer_modules.items()} + for directory, before in before_modules.items(): + after = after_modules[directory] + changed_go = [] + changed_producers = [] + for name, item in producers.items(): + module = f"github.com/notaryproject/{name}" + def dependency(manifest): + return ([entry for entry in manifest.get("Require") or [] if entry["Path"] == module], + [entry for entry in manifest.get("Replace") or [] if entry["Old"]["Path"] == module]) + if dependency(before) != dependency(after): + if release.requirement(after, module, item["repository"], MODE) != item["version"]: + return False + changed = True + changed_go.append(producer_go[name]) + changed_producers.append(name) + floor = max([go_version(before["Go"]), *changed_go]) + if before["Go"] != after["Go"] and go_version(after["Go"]) != floor: + return False + if before.get("Toolchain") != after.get("Toolchain"): + chain = after.get("Toolchain") or "" + if not isinstance(chain, str) or not chain.startswith("go"): + return False + if not floor <= go_version(chain.removeprefix("go")) <= go_version(current.removeprefix("go")): + return False + required = {} + for name in changed_producers: + for entry in producer_modules[name].get("Require") or []: + path, version = entry["Path"], entry["Version"] + if path not in required or module_version(version) > module_version(required[path]): + required[path] = version + original = {entry["Path"]: entry for entry in before.get("Require") or []} + updated = {entry["Path"]: entry for entry in after.get("Require") or []} + for path, previous in original.items(): + if path not in allowed and not previous.get("Indirect") and path in required: + if (module_version(required[path]) > module_version(previous["Version"]) + and updated.get(path, {}).get("Version") != required[path]): + return False + def unrelated(manifest, normalize=False): + result = copy.deepcopy(manifest) + # MVS can change indirect requirements when a producer is updated. + result["Require"] = [item for item in result.get("Require") or [] if item["Path"] not in allowed and not item.get("Indirect")] + for entry in result["Require"]: + path = entry["Path"] + previous = original.get(path) + if (normalize and previous is not None and path in required + and entry["Version"] == required[path] + and module_version(required[path]) > module_version(previous["Version"])): + entry["Version"] = previous["Version"] + result["Replace"] = [item for item in result.get("Replace") or [] if item["Old"]["Path"] not in allowed] + result.pop("Go", None) + result.pop("Toolchain", None) + return result + if unrelated(before) != unrelated(after, normalize=True): + return False + return changed + + +def producer_pull(api, repository, pull, producers): + """Permit new propagation PRs, but not unrelated changes hidden in grouped updates.""" + if not producers or (pull["head"].get("repo") or {}).get("full_name") != repository: + return False + files = list(api.pages(f"repos/{repository}/pulls/{pull['number']}/files")) + paths = {f"{directory}/go.{extension}".removeprefix("./") for directory in release.modules(repository) + for extension in ("mod", "sum")} + if not files or any(item["filename"] not in paths or (item.get("previous_filename") and item["previous_filename"] not in paths) for item in files): + return False + before = {directory: api.manifest(repository, pull["base"]["sha"], directory) for directory in release.modules(repository)} + after = {directory: api.manifest(repository, pull["head"]["sha"], directory) for directory in release.modules(repository)} + producer_modules = {name: api.manifest(item["repository"], item["version"], ".") for name, item in producers.items()} + current = release.run(["go", "env", "GOVERSION"]).strip() + return producer_manifests(before, after, producers, producer_modules, current) + + +def scoped_pull(api, repository, pull, authorization, merged=()): + snapshot = authorization["snapshot"] + heads = {item["number"]: item["head"] for item in snapshot["pulls"]} + commits = {item["number"]: item["commit"] for item in snapshot["merged"]} + number = pull["number"] + approved_merges = {item["number"]: item["commit"] for item in merged} + if number in approved_merges and pull.get("merged_at"): + if pull.get("merge_commit_sha") != approved_merges[number]: + raise ValueError("Worker-approved dependency merge changed") + return True + if number in heads: + if pull["head"]["sha"] != heads[number]: + raise ValueError("An assessed Dependabot PR head changed; reassessment is required") + return True + if number in commits: + if pull.get("merge_commit_sha") != commits[number]: + raise ValueError("Assessed dependency backport provenance changed") + return True + if pull.get("user", {}).get("login") != "dependabot[bot]": + return False + return producer_pull(api, repository, pull, authorization["producers"]) + + +def trusted_assessment(api, run_id, output): + run_id = positive_id(run_id) + run = api.request(f"repos/{HOST}/actions/runs/{run_id}") + workflow = api.request(f"repos/{HOST}/actions/workflows/notation-release-agent.lock.yml") + metadata = repository_metadata(api, HOST) + if (run.get("workflow_id") != workflow["id"] or run.get("path") != AGENT_PATH + or run.get("head_branch") != metadata["default_branch"] or run.get("head_repository", {}).get("full_name") != HOST + or run.get("event") not in {"schedule", "workflow_dispatch"} or run.get("conclusion") != "success"): + raise ValueError("Assessment must come from a successful trusted default-branch agent run") + artifacts = api.request(f"repos/{HOST}/actions/runs/{run_id}/artifacts?per_page=100") + if artifacts["total_count"] > 100: + raise ValueError("Unexpected assessment artifact count") + matches = [item for item in artifacts["artifacts"] if item["name"] == "notation-release-assessment" and not item["expired"]] + if len(matches) != 1: + raise ValueError("Missing or duplicate validated assessment artifact") + artifact = matches[0] + if artifact.get("workflow_run", {}).get("head_sha") != run["head_sha"]: + raise ValueError("Assessment artifact provenance changed") + release.run(["gh", "run", "download", str(run_id), "--repo", HOST, "--name", artifact["name"], "--dir", str(output)]) + envelope = load_json((output / "assessment.json").read_text()) + expected = validate_assessment(envelope["inventory"], envelope["assessment"]) + if expected != envelope["plan"]: + raise ValueError("Validated assessment was modified") + assessed_automation(api, HOST, expected["snapshots"][HOST], run["head_sha"]) + return expected + + +def notify_controller_failure(api): + if (os.environ.get("GITHUB_REPOSITORY") != HOST or os.environ.get("GITHUB_REF") != "refs/heads/main" + or os.environ.get("MONTHLY_PATCH_TOKEN_READY") != "true" + or not os.environ.get("MONTHLY_PATCH_ACTOR")): + raise ValueError("Failure notification requires the trusted controller and its scoped credential") + actor = os.environ["MONTHLY_PATCH_ACTOR"] + if api.request("user")["login"] != actor: + raise ValueError("Failure notification credential belongs to another actor") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not run_id.isdecimal() or int(run_id) <= 0: + raise ValueError("Failure notification requires the actual controller run") + run = api.request(f"repos/{HOST}/actions/runs/{run_id}") + workflow = api.request(f"repos/{HOST}/actions/workflows/notation-release-controller.yml") + if (run.get("workflow_id") != workflow["id"] + or run.get("path") != ".github/workflows/notation-release-controller.yml" + or run.get("head_branch") != "main" or run.get("head_sha") != os.environ.get("GITHUB_SHA") + or run.get("repository", {}).get("full_name") != HOST + or run.get("event") != os.environ.get("GITHUB_EVENT_NAME") + or run.get("event") not in {"schedule", "repository_dispatch", "workflow_dispatch"}): + raise ValueError("Failure notification does not identify the trusted controller run") + controller = Controller(api, actor) + records = [(issue, state) for issue, state in controller.records() if state["status"] in {"active", "review"}] + if not records: + print("No trusted release issue exists; inspect the failed GitHub Actions run.", file=sys.stderr) + return {"action": "no-release-issue", "run_id": int(run_id)} + issue, state = records[0] + controller.report_failure( + issue, state, HOST, run, "Controller validation or execution failed; inspect the linked logs", ["controller"], + ) + return {"action": "notified", "issue": issue["number"], "run_id": int(run_id)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["collect", "validate", "control", "preview", "notify-failure"]) + parser.add_argument("--month", default=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m")) + parser.add_argument("--inventory", type=pathlib.Path) + parser.add_argument("--agent-output", type=pathlib.Path) + parser.add_argument("--assessment-run", type=int) + parser.add_argument("--mode", choices=["dry-run", MODE], default="dry-run") + parser.add_argument("--approve", action="store_true") + parser.add_argument("--retry-failed", action="store_true") + parser.add_argument("--replan", action="store_true") + parser.add_argument("--recover-public-verification", action="store_true") + parser.add_argument("--output", type=pathlib.Path, required=True) + args = parser.parse_args() + if args.recover_public_verification and not (args.assessment_run and args.approve and args.replan and args.mode == MODE): + parser.error("Public verification recovery requires a fresh assessment, writing mode, approval and replan") + try: + args.output.mkdir(parents=True, exist_ok=True) + api = release.GitHub() + if args.command == "preview": + result = preview_scope(api, args.output, args.inventory) + elif args.command == "notify-failure": + if args.mode != MODE: + raise ValueError("Read-only dry runs cannot write failure comments") + result = notify_controller_failure(api) + elif args.command == "collect": + inventory = collect(api, args.month, args.output / "scans") + validate_inventory(inventory) + (args.output / "inventory.json").write_text(json.dumps(inventory, indent=2) + "\n") + (args.output / "inventory.sha256").write_text(digest(inventory) + "\n") + result = {"inventory_sha256": digest(inventory), "month": args.month, "writes": False} + elif args.command == "validate": + inventory = load_json(args.inventory.read_text()) + items = load_json(args.agent_output.read_text())["items"] + if len(items) != 1 or items[0].get("type") != "submit_release_plan": + raise ValueError("Agent must submit exactly one structured assessment") + assessment = load_json(items[0]["assessment_json"]) + plan = validate_assessment(inventory, assessment) + result = {"inventory": inventory, "assessment": assessment, "plan": plan} + (args.output / "assessment.json").write_text(json.dumps(result, indent=2) + "\n") + else: + if os.environ.get("GITHUB_REPOSITORY") != HOST or os.environ.get("GITHUB_REF") != "refs/heads/main": + raise ValueError("Controller must run from trusted default main") + if args.mode != "dry-run" and ( + os.environ.get("NOTATION_RELEASE_COORDINATOR_ENABLED") != "true" + or not os.environ.get("MONTHLY_PATCH_ACTOR") + or os.environ.get("MONTHLY_PATCH_TOKEN_READY") != "true" + ): + raise ValueError("Writing controller requires explicit opt-in and its scoped credential") + if args.mode != "dry-run": + identity = api.request("user")["login"] + if identity != os.environ["MONTHLY_PATCH_ACTOR"]: + raise ValueError("Release credential does not belong to the configured actor") + if args.approve or args.replan or args.retry_failed or args.recover_public_verification: + explicit_actor() + if os.environ.get('MONTHLY_PATCH_ENABLED') != 'true': + raise ValueError("Canonical monthly release execution is disabled") + controller = Controller(api, os.environ["MONTHLY_PATCH_ACTOR"]) + existing = controller.records() + if args.assessment_run is None and not any(state["status"] in {"active", "review"} for _, state in existing): + runs = api.request(f"repos/{HOST}/actions/workflows/notation-release-agent.lock.yml/runs?status=success&per_page=1")["workflow_runs"] + if runs: + args.assessment_run = positive_id(runs[0]["id"]) + if args.assessment_run is not None: + plan = trusted_assessment(api, args.assessment_run, args.output / "download") + if args.mode == "dry-run": + result = {"plan": plan, "writes": False} + else: + controller = Controller(api, os.environ["MONTHLY_PATCH_ACTOR"]) + records = controller.records() + active = [(issue, state) for issue, state in records if state["status"] in {"active", "review"}] + completed = any(state["month"] == plan["month"] and state["status"] == "completed" for _, state in records) + if not args.approve and active: + issue, state = active[0] + elif not args.approve and completed: + result = {"reason": "Assessed cycle is already completed", "writes": False} + (args.output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + return 0 + else: + review = None + if not args.approve: + try: + review_scope(api, plan) + except ValueError as error: + review = {"blocker": str(error)} + issue, state = controller.approve(plan, args.assessment_run, args.replan, + args.recover_public_verification, review) + issue, state = controller.advance(issue, state, args.retry_failed) + result = {"issue": issue["number"], "state": state} + elif args.mode == "dry-run": + result = {"writes": False, "reason": "Supply an assessment run to preview its validated plan"} + else: + controller = Controller(api, os.environ["MONTHLY_PATCH_ACTOR"]) + records = [(issue, state) for issue, state in controller.records() if state["status"] in {"active", "review"}] + if records: + issue, state = controller.advance(*records[0], retry_failed=args.retry_failed) + result = {"issue": issue["number"], "state": state} + else: + result = {"reason": "No approved active cycle to resume"} + (args.output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + return 0 + except (ValueError, KeyError, TypeError, OSError, RuntimeError) as error: + print(f"Notation release coordinator failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/qualify_monthly_candidate.py b/.github/scripts/qualify_monthly_candidate.py new file mode 100644 index 000000000..d9610169b --- /dev/null +++ b/.github/scripts/qualify_monthly_candidate.py @@ -0,0 +1,82 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Qualify the exact candidate with existing module, race, E2E and license checks.""" + +import argparse +import json +import pathlib +import re + +from monthly_release import modules +from monthly_release_checks import checked, scan + + +def license_config(source, module_directories): + config = (source / ".github/licenserc.yml").read_text(encoding="utf-8").rstrip() + "\n" + pattern = r"(?m)(^dependency:\n files:\n)(?: - [^\n]+\n)+" + if len(re.findall(pattern, config)) != 1: + raise ValueError("Repository license policy needs an explicit dependency.files list") + files = "".join(f" - {json.dumps(str(source / directory / 'go.mod'))}\n" for directory in module_directories) + return re.sub(pattern, lambda match: match[1] + files, config) + + +def qualify(source, repository, evidence): + source, evidence = source.resolve(), evidence.resolve() + if evidence == source or source in evidence.parents: + raise ValueError("Evidence must be outside the qualified checkout") + evidence.mkdir(parents=True, exist_ok=True) + module_directories = modules(repository) + root = json.loads(checked(["go", "mod", "edit", "-json"], evidence / "manifest.json", source)) + version = checked(["go", "version"], evidence / "compiler.txt", source) + compiler = re.search(r"go(\d+)\.(\d+)(?:\.(\d+))?", version) + if not compiler: + raise ValueError("Cannot identify qualification compiler") + current = tuple(int(part or 0) for part in compiler.groups()) + for directory in module_directories: + name = "root" if directory == "." else directory.replace("/", "-") + module = source / directory + manifest = json.loads(checked(["go", "mod", "edit", "-json"], evidence / f"{name}-manifest.json", module)) + if repository.startswith("notaryproject/") and manifest.get("Replace"): + raise ValueError(f"Production release manifest contains replacements: {directory}/go.mod") + minimum = tuple(int(part) for part in manifest["Go"].split(".")) + minimum += (0,) * (3 - len(minimum)) + root_minimum = tuple(int(part) for part in root["Go"].split(".")) + root_minimum += (0,) * (3 - len(root_minimum)) + if current < minimum or root_minimum < minimum: + raise ValueError("Root minimum Go must cover every tested module minimum") + for command in (["go", "mod", "download"], ["go", "mod", "verify"], ["go", "mod", "tidy"], ["go", "vet", "./..."]): + checked(command, evidence / f"{name}-{'-'.join(command[1:3])}.log", module) + if directory != "test/e2e": + checked(["go", "test", "-race", "-covermode=atomic", + f"-coverprofile={evidence / (name + '.out')}", "./..."], + evidence / f"{name}-tests.log", module) + checked(["go", "tool", "cover", f"-func={evidence / (name + '.out')}"], + evidence / f"{name}-coverage.txt", module) + scan(["govulncheck", "-format=json", "-test", "./..."], evidence / f"{name}-scan", "source", module) + checked(["git", "diff", "--exit-code", "--", "go.mod", "go.sum", "**/go.mod", "**/go.sum"], evidence / "module-integrity.log", source) + config = evidence / "licenses.yml" + config.write_text(license_config(source, module_directories), encoding="utf-8") + checked(["license-eye", "-c", str(config), "dependency", "check", "--weak-compatible=true"], evidence / "licenses.log", source) + if repository.endswith("/notation"): + checked(["make", "e2e"], evidence / "source-e2e.log", source) + checked(["git", "diff", "--exit-code", "--", "go.mod", "go.sum", "**/go.mod", "**/go.sum"], evidence / "final-integrity.log", source) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=pathlib.Path) + parser.add_argument("--repository", required=True) + parser.add_argument("--evidence", type=pathlib.Path, required=True) + args = parser.parse_args() + qualify(args.source, args.repository, args.evidence) diff --git a/.github/scripts/release_bugfix_proposals.py b/.github/scripts/release_bugfix_proposals.py new file mode 100644 index 000000000..4d102a70e --- /dev/null +++ b/.github/scripts/release_bugfix_proposals.py @@ -0,0 +1,487 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Read-only evidence for human-reviewed main-to-release bug-fix proposals. + +``collect`` accepts immutable full SHAs, verifies the live main before and after +collection, and uses only GET requests. It never checks out or executes source. +Bounds fail closed, or produce explicit evidence blockers, never silent success. +``validate_proposals`` accepts AI assessments against a *trusted collector result*, +not an independently supplied evidence document. Its digest binds that snapshot; +it is not an authentication signature. Neither API grants worker authority. + +Commit messages supply provenance, not proof of textual equivalence or bug-fix +intent. Squashes without provenance, nonstandard reverts, conflicts, and feature +prerequisites cannot be proved by this collector. Applicability always requires +separate review. Titles, labels, and PR mergeability are not classifiers. +""" + +import hashlib +import json +import re +from urllib.parse import quote + +from monthly_release import REPOSITORY, SHA + + +MAX_COMMITS = 200 +MAX_HISTORY = 200 +MAX_FILES = 100 +MAX_PRS = 5 +MAX_PR_COMMITS = 200 +MAX_PATCH_BYTES = 100_000 +MAX_EVIDENCE_BYTES = 10_000_000 +MAX_REASON = 4000 +CLASSIFICATIONS = {"candidate_bugfix", "feature", "not_bugfix", "needs_review"} +CHERRY_PICK = re.compile(r"(?m)^\(cherry picked from commit ([0-9a-f]{40})\)$") +REVERT = re.compile(r"(?m)^This reverts commit ([0-9a-f]{40})(?:\.|,)") + + +def digest(value): + return hashlib.sha256(json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, + ).encode()).hexdigest() + + +def _sha(value): + if not isinstance(value, str) or not SHA.fullmatch(value): + raise ValueError("Expected an immutable full commit SHA") + return value + + +def _get(api, repository, path): + # Deliberately do not catch HTTP errors, including missing/inaccessible diffs. + return api.request(f"repos/{repository}/{path}") + + +def _main(api, repository, expected): + if _get(api, repository, "commits/main")["sha"] != expected: + raise ValueError("Main drifted; recollect against the current main SHA") + + +def _comparison(api, repository, base, head): + commits = [] + snapshot = None + for page in range(1, MAX_COMMITS // 100 + 2): + data = _get(api, repository, + f"compare/{base}...{head}?per_page=100&page={page}") + current = { + "status": data["status"], "ahead_by": data["ahead_by"], + "behind_by": data["behind_by"], "total_commits": data["total_commits"], + "base": data["base_commit"]["sha"], + "merge_base": data["merge_base_commit"]["sha"], + } + if snapshot is None: + snapshot = current + status, ahead, behind = (current[key] for key in + ("status", "ahead_by", "behind_by")) + if (current["base"] != base or status not in + {"ahead", "behind", "identical", "diverged"}): + raise ValueError("Invalid comparison ancestry") + _sha(current["merge_base"]) + if (type(ahead) is not int or type(behind) is not int or + min(ahead, behind) < 0 or current["total_commits"] != ahead): + raise ValueError("Invalid comparison counts") + expected_status = ("diverged" if ahead and behind else + "ahead" if ahead else "behind" if behind else "identical") + if status != expected_status or ( + not behind and current["merge_base"] != base): + raise ValueError("Inconsistent comparison status or merge base") + if ((status == "identical" and base != head) or + (status == "behind" and current["merge_base"] != head) or + (status == "diverged" and current["merge_base"] in {base, head})): + raise ValueError("Comparison merge base contradicts ancestry") + if ahead > MAX_COMMITS: + raise ValueError("Comparison exceeds commit bound; manual review required") + elif current != snapshot: + raise ValueError("Comparison changed during pagination") + batch = data["commits"] + if not isinstance(batch, list) or len(batch) > 100: + raise ValueError("Invalid comparison page") + commits.extend(batch) + shas = [_sha(item["sha"]) for item in commits] + if len(shas) != len(set(shas)) or len(shas) > snapshot["ahead_by"]: + raise ValueError("Duplicate or excess comparison commits") + if len(shas) == snapshot["ahead_by"]: + if shas and shas[-1] != head: + raise ValueError("Comparison does not reach the pinned head") + return {**snapshot, "source_shas": shas} + if len(batch) < 100: + raise ValueError("Incomplete comparison pagination") + raise ValueError("Comparison pagination bound exceeded") + + +def _list(api, repository, path, limit): + items = [] + for page in range(1, limit // 100 + 2): + separator = "&" if "?" in path else "?" + batch = _get(api, repository, + f"{path}{separator}per_page=100&page={page}") + if not isinstance(batch, list) or len(batch) > 100: + raise ValueError("Invalid list page") + items.extend(batch) + if len(items) > limit: + return items[:limit], False + if len(batch) < 100: + return items, True + raise ValueError("List pagination bound exceeded") + + +def _history(api, repository, head): + records, complete = _list(api, repository, f"commits?sha={head}", MAX_HISTORY) + shas = [_sha(item["sha"]) for item in records] + if not shas or shas[0] != head or len(shas) != len(set(shas)): + raise ValueError("Invalid pinned release history") + return records, complete + + +def _patch_complete(patch, additions, deletions): + """Reject missing or truncated unified hunks, including partial API patches.""" + if not isinstance(patch, str) or not patch or len(patch.encode()) > MAX_PATCH_BYTES: + return False + old = new = added = removed = 0 + saw_hunk = False + for line in patch.splitlines(): + hunk = re.fullmatch(r"@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@.*", line) + if hunk: + if old or new: + return False + old = int(hunk[1]) if hunk[1] is not None else 1 + new = int(hunk[2]) if hunk[2] is not None else 1 + saw_hunk = True + elif line == r"\ No newline at end of file" and saw_hunk: + continue + elif not saw_hunk or not line or line[0] not in " +-": + return False + else: + if line[0] in " -": + old -= 1 + if line[0] in " +": + new -= 1 + added += line[0] == "+" + removed += line[0] == "-" + if old < 0 or new < 0: + return False + return saw_hunk and old == new == 0 and added == additions and removed == deletions + + +def _files(repository, sha, files): + result = [] + for item in files: + path = item["filename"] + if not isinstance(path, str) or not path or len(path) > 4096: + raise ValueError("Invalid diff filename") + patch = item.get("patch") + available = _patch_complete(patch, item["additions"], item["deletions"]) + result.append({ + "filename": path, "previous_filename": item.get("previous_filename"), + "status": item["status"], "additions": item["additions"], + "deletions": item["deletions"], "changes": item["changes"], + "patch": patch if available else None, + "patch_available": available, + "url": f"https://github.com/{repository}/commit/{sha}#diff-" + + hashlib.sha256(path.encode()).hexdigest(), + }) + if len({item["filename"] for item in result}) != len(result): + raise ValueError("Duplicate diff files") + return result + + +def _commit(api, repository, sha): + data = _get(api, repository, f"commits/{sha}?per_page=100&page=1") + if data["sha"] != sha: + raise ValueError("Commit endpoint returned another SHA") + # The commit endpoint caps file output. Refuse to call a full page complete. + files = _files(repository, sha, data["files"][:MAX_FILES]) + stats = data["stats"] + complete = (len(data["files"]) < MAX_FILES and + sum(item["additions"] for item in files) == stats["additions"] and + sum(item["deletions"] for item in files) == stats["deletions"] and + all(item["patch_available"] for item in files) and bool(files)) + return { + "sha": sha, "parents": [_sha(item["sha"]) for item in data["parents"]], + "message": data["commit"]["message"], + "url": f"https://github.com/{repository}/commit/{sha}", + "files": files, "diff_complete": complete, + } + + +def _ancestor(api, repository, base, head): + data = _get(api, repository, f"compare/{base}...{head}?per_page=1&page=1") + return (data["base_commit"]["sha"] == base and + data["merge_base_commit"]["sha"] == base and + data["status"] in {"ahead", "identical"} and data["behind_by"] == 0) + + +def _pulls(api, repository, source, main, main_shas): + associated, complete = _list( + api, repository, f"commits/{source}/pulls", MAX_PRS, + ) + pulls = [] + blockers = [] if complete else ["associated_pr_scan_incomplete"] + seen = set() + for association in associated: + number = association["number"] + if type(number) is not int or number <= 0 or number in seen: + raise ValueError("Invalid associated PR number") + seen.add(number) + pull = _get(api, repository, f"pulls/{number}") + if pull["number"] != number: + raise ValueError("Associated PR identity mismatch") + if not pull.get("merged_at"): + continue + if (pull["base"]["repo"]["full_name"] != repository or + pull["base"]["ref"] != "main"): + continue + merge = _sha(pull["merge_commit_sha"]) + if merge not in main_shas and not _ancestor(api, repository, merge, main): + blockers.append("pr_merge_not_on_pinned_main") + continue + commits, commits_complete = _list( + api, repository, f"pulls/{number}/commits", MAX_PR_COMMITS, + ) + commit_shas = [_sha(item["sha"]) for item in commits] + if len(commit_shas) != len(set(commit_shas)): + raise ValueError("Duplicate PR commits") + if merge != source and (not commits_complete or source not in commit_shas): + blockers.append("pr_source_membership_unverified") + continue + head = _sha(pull["head"]["sha"]) + base = _sha(pull["base"]["sha"]) + raw_files, files_complete = _list( + api, repository, f"pulls/{number}/files", MAX_FILES, + ) + files = _files(repository, merge, raw_files) + diff_complete = (files_complete and bool(files) and + len(files) == pull["changed_files"] and + all(item["patch_available"] for item in files)) + pulls.append({ + "number": number, "repository": repository, "base_ref": "main", + "base_sha": base, "head_sha": head, "merge_sha": merge, + "merged_at": pull["merged_at"], "title": pull["title"], + "body": pull.get("body"), "commit_shas": commit_shas, + "commits_complete": commits_complete, + "url": f"https://github.com/{repository}/pull/{number}", + "immutable_diff_url": f"https://github.com/{repository}/compare/{base}...{head}", + "files": files, "diff_complete": diff_complete, + }) + if len(pulls) != 1: + blockers.append("missing_or_ambiguous_merged_canonical_pr") + if any(not pull["diff_complete"] for pull in pulls): + blockers.append("pr_diff_unavailable_or_incomplete") + return pulls, blockers + + +def _provenance(records): + return [{ + "sha": _sha(item["sha"]), + "cherry_picks": CHERRY_PICK.findall(item["commit"]["message"]), + "reverts": REVERT.findall(item["commit"]["message"]), + } for item in records] + + +def collect(api, repository, main_head, release_head): + """Collect bounded evidence, not an execution plan or bug-fix classification. + + Missing patches (including binary files), uncertain provenance, multiple PRs, + and merge commits block a bug-fix proposal. HTTP errors propagate. The caller + owns selection and freshness of the release SHA; no release branch is assumed. + A truncated release history explicitly blocks equivalence confirmation. + """ + if not isinstance(repository, str) or not REPOSITORY.fullmatch(repository): + raise ValueError("Expected a canonical Notary repository") + _sha(main_head) + _sha(release_head) + _main(api, repository, main_head) + comparison = _comparison(api, repository, release_head, main_head) + release_records, history_complete = _history(api, repository, release_head) + release_provenance = _provenance(release_records) + release_shas = {item["sha"] for item in release_provenance} + release_reverted = {sha for item in release_provenance for sha in item["reverts"]} + commits = [] + for sha in comparison["source_shas"]: + commits.append(_commit(api, repository, sha)) + if len(json.dumps(commits).encode()) > MAX_EVIDENCE_BYTES: + raise ValueError("Evidence byte bound exceeded; manual review required") + main_provenance = _provenance([ + {"sha": item["sha"], "commit": {"message": item["message"]}} + for item in commits + ]) + main_reverted = {sha for item in main_provenance for sha in item["reverts"]} + candidates = [] + evidence_records = {} + for commit in commits: + source = commit["sha"] + pulls, blockers = _pulls( + api, repository, source, main_head, set(comparison["source_shas"]), + ) + included = sorted(item["sha"] for item in release_provenance + if source in item["cherry_picks"]) + # Also handle a main-side cherry-pick whose original is on release. + originals = CHERRY_PICK.findall(commit["message"]) + included.extend(sorted(set(originals) & release_shas)) + known_reverts = sorted({item["sha"] for item in main_provenance + if source in item["reverts"]}) + release_reverts = sorted({item["sha"] for item in release_provenance + if source in item["reverts"] or + set(item["reverts"]) & set(included)}) + inclusion = "not_observed" + if included: + inclusion = "cherry_pick_provenance" + blockers.append("already_present_by_cherry_pick_provenance") + if source in main_reverted or known_reverts or release_reverts: + blockers.append("known_revert_requires_review") + if REVERT.search(commit["message"]): + blockers.append("revert_commit_requires_review") + if any(sha in release_reverted for sha in included): + blockers.append("release_equivalent_was_reverted") + if not history_complete: + blockers.append("release_history_incomplete") + if not commit["diff_complete"]: + blockers.append("source_diff_unavailable_or_incomplete") + if len(commit["parents"]) != 1: + blockers.append("non_single_parent_source") + ids = [] + for kind, record in [("commit", commit)] + [("pr", pull) for pull in pulls]: + evidence_id = f"{kind}:{digest(record)}" + evidence_records[evidence_id] = record + ids.append(evidence_id) + candidates.append({ + "candidate_id": f"{repository}@{source}", + "repository": repository, "source_sha": source, + "main_member": True, "inclusion": inclusion, + "equivalent_shas": sorted(set(included)), + "known_revert_shas": known_reverts, + "release_revert_shas": release_reverts, + "pr_numbers": [pull["number"] for pull in pulls], + "evidence_ids": ids, + "blockers": sorted(set(blockers + ["applicability_unverified"])), + }) + if len(json.dumps(evidence_records).encode()) > MAX_EVIDENCE_BYTES: + raise ValueError("Evidence byte bound exceeded; manual review required") + _main(api, repository, main_head) + return { + "schema": 1, "authority": "proposal_only", "automatic_inclusion": False, + "repository": repository, "main_head": main_head, "release_head": release_head, + "comparison": comparison, "release_history_complete": history_complete, + "release_provenance": release_provenance, + "candidates": candidates, "evidence": evidence_records, + "limits": { + "commits": MAX_COMMITS, "release_history": MAX_HISTORY, + "files_per_diff": MAX_FILES, "associated_prs": MAX_PRS, + "pr_commits": MAX_PR_COMMITS, "patch_bytes": MAX_PATCH_BYTES, + "evidence_bytes": MAX_EVIDENCE_BYTES, + }, + } + + +def _keys(value, expected): + if not isinstance(value, dict) or set(value) != set(expected.split()): + raise ValueError("Unexpected or missing proposal fields") + + +def _reason(value): + if not isinstance(value, str) or not value.strip() or len(value) > MAX_REASON: + raise ValueError("A bounded evidence-based rationale is required") + + +def validate_proposals(evidence, proposed): + """Bind untrusted AI proposals to trusted, freshly collected evidence. + + The caller must retain the original collector result, not accept replacement + evidence from the model. Rationale quality remains a human review obligation. + Even a valid ``candidate_bugfix`` is blocked pending applicability review and + cannot be included automatically. Unverified conflict/prerequisite claims + add blockers, never execution permission. + """ + _keys(proposed, "schema evidence_sha256 proposals") + if (type(proposed["schema"]) is not int or proposed["schema"] != 1 or + evidence["schema"] != 1 or + evidence["authority"] != "proposal_only" or + evidence["automatic_inclusion"] is not False or + proposed["evidence_sha256"] != digest(evidence)): + raise ValueError("Proposal evidence snapshot mismatch") + if not isinstance(proposed["proposals"], list): + raise ValueError("Expected a list of proposals") + candidates = {item["candidate_id"]: item for item in evidence["candidates"]} + source_shas = set(evidence["comparison"]["source_shas"]) + seen, validated = set(), [] + for proposal in proposed["proposals"]: + _keys(proposal, "candidate_id repository source_sha pr_number classification " + "evidence_ids rationale applicability") + candidate_id = proposal["candidate_id"] + if not isinstance(candidate_id, str) or candidate_id not in candidates or candidate_id in seen: + raise ValueError("Unknown or duplicate candidate") + seen.add(candidate_id) + candidate = candidates[candidate_id] + source = _sha(proposal["source_sha"]) + if (proposal["repository"] != evidence["repository"] or + proposal["repository"] != candidate["repository"] or + source != candidate["source_sha"] or source not in source_shas or + not candidate["main_member"]): + raise ValueError("Proposal repository/source/main membership mismatch") + classification = proposal["classification"] + if not isinstance(classification, str) or classification not in CLASSIFICATIONS: + raise ValueError("Unsupported proposal classification") + number = proposal["pr_number"] + if number is not None and ( + type(number) is not int or number not in candidate["pr_numbers"]): + raise ValueError("Fabricated or unrelated PR") + citations = proposal["evidence_ids"] + if (not isinstance(citations, list) or not citations or + any(not isinstance(item, str) for item in citations) or + len(citations) != len(set(citations)) or + not set(citations) <= set(candidate["evidence_ids"])): + raise ValueError("Fabricated or unrelated evidence citation") + for evidence_id in citations: + record = evidence["evidence"][evidence_id] + kind, fingerprint = evidence_id.split(":", 1) + if kind not in {"commit", "pr"} or digest(record) != fingerprint: + raise ValueError("Evidence content changed") + if not any(item.startswith("commit:") for item in citations): + raise ValueError("Source diff evidence citation is required") + if classification == "candidate_bugfix": + if (set(candidate["blockers"]) - {"applicability_unverified"} or + candidate["pr_numbers"] != [number]): + raise ValueError("Incomplete, ambiguous, included, or reverted source is blocked") + if not any(item.startswith("pr:") and + evidence["evidence"][item]["number"] == number for item in citations): + raise ValueError("Merged canonical PR diff citation is required") + _reason(proposal["rationale"]) + applicability = proposal["applicability"] + _keys(applicability, "status rationale prerequisite_shas") + status = applicability["status"] + if (not isinstance(status, str) or status not in + {"unverified", "conflict", "missing_prerequisite", "not_applicable"}): + raise ValueError("Applicability cannot be asserted as verified by this module") + _reason(applicability["rationale"]) + prerequisites = applicability["prerequisite_shas"] + if (not isinstance(prerequisites, list) or + any(not isinstance(sha, str) or sha not in source_shas or sha == source + for sha in prerequisites) or len(prerequisites) != len(set(prerequisites))): + raise ValueError("Fabricated or unrelated prerequisite") + if (status == "missing_prerequisite") != bool(prerequisites): + raise ValueError("Missing-prerequisite assessment must cite observed source SHAs") + blockers = set(candidate["blockers"]) + if status != "unverified": + blockers.add(f"applicability_{status}") + validated.append({ + **proposal, "authority": "proposal_only", "automatic_inclusion": False, + "blocked": True, "blockers": sorted(blockers), + }) + return { + "schema": 1, "authority": "proposal_only", "automatic_inclusion": False, + "repository": evidence["repository"], "main_head": evidence["main_head"], + "release_head": evidence["release_head"], "evidence_sha256": digest(evidence), + "proposals": validated, + } diff --git a/.github/scripts/release_issue_topology.py b/.github/scripts/release_issue_topology.py new file mode 100644 index 000000000..ab7abf48f --- /dev/null +++ b/.github/scripts/release_issue_topology.py @@ -0,0 +1,230 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""One human release thread per repository, separate from technical cycle state.""" +import hashlib +import html +import re + +PROJECTS = {"notation-core-go": (), "notation-go": ("notation-core-go",), + "notation": ("notation-core-go", "notation-go")} + + +def text(value): + escaped = html.escape(" ".join(str(value).split())[:1000], quote=True) + return re.sub(r"[\\`*_{}\[\]()#+.!|@]", lambda match: f"&#{ord(match[0])};", escaped) + + +def body_hash(body): + return hashlib.sha256(body.encode()).hexdigest() + + +def repositories(host): + if not re.fullmatch(r"[A-Za-z0-9-]+/notation", host): + raise ValueError("Invalid release issue host") + owner = host.split("/")[0] + return tuple(f"{owner}/{name}" for name in PROJECTS) + + +def validate_routes(host, routes, complete=True): + expected = set(repositories(host)) + if (not isinstance(routes, dict) or not set(routes) <= expected + or (complete and set(routes) != expected) + or any(type(value) is not int or value <= 0 for value in routes.values())): + raise ValueError("Invalid release issue routing") + return routes + + +def issue_url(repository, number): + if not re.fullmatch(r"[A-Za-z0-9-]+/(notation-core-go|notation-go|notation)", repository): + raise ValueError("Invalid release issue repository") + if type(number) is not int or number <= 0: + raise ValueError("Invalid release issue number") + return f"https://github.com/{repository}/issues/{number}" + + +def marker(plan, repository): + if repository not in repositories(plan["host"]) or not re.fullmatch(r"\d{4}-\d{2}", plan["month"]): + raise ValueError("Invalid release scope identity") + return f"" + + +def dependency_links(plan, repository, routes): + validate_routes(plan["host"], routes) + owner, name = repository.split("/") + if repository not in routes: + raise ValueError("Missing release issue route") + dependencies = [] + for dependency in PROJECTS[name]: + producer = f"{owner}/{dependency}" + dependencies.append(f"[{producer}#{routes[producer]}]({issue_url(producer, routes[producer])})") + return "Dependencies: " + ("; ".join(dependencies) if dependencies else "None.") + "\n" + + +def scope_body(plan, repository, routes): + validate_routes(plan["host"], routes) + snapshot = plan["snapshots"][repository] + lines = [marker(plan, repository), f"# {repository} patch release: {plan['month']}", "", + f"Current version: `{snapshot['baseline']}`. Proposed patch: `{snapshot['tag']}`.", "", + "## Changes", "", + "The scope merges qualifying Dependabot PRs and cuts a patch release.", ""] + source_links = [] + for field, revision, label in (("pulls", "head", plan.get("scope_label", "Proposed HEAD")), + ("merged", "commit", "Original source merge")): + for item in snapshot[field]: + number, sha = item["number"], item[revision] + if (type(number) is not int or number <= 0 or not isinstance(sha, str) + or not re.fullmatch(r"[0-9a-f]{40}", sha)): + raise ValueError("Invalid release scope PR identity") + additional = [] + for commit in item.get("commits", []): + if not re.fullmatch(r"[0-9a-f]{40}", commit["sha"]): + raise ValueError("Invalid proposed commit") + if commit["sha"] != sha: + additional.append(f"[`{commit['sha'][:12]}`](https://github.com/{repository}/commit/{commit['sha']}) " + f"{text(commit.get('subject', ''))}") + changes = item.get("dependency_changes", []) + change_text = "; ".join(f"`{text(change['module'])}` `{text(change['before'])}` to `{text(change['after'])}`" + for change in changes) + lines.append(f"- [#{number}](https://github.com/{repository}/pull/{number}): " + f"{change_text or text(item.get('title', 'Dependabot dependency update'))}.") + source_links.append(f"- PR #{number} {label}: [`{sha[:12]}`]" + f"(https://github.com/{repository}/commit/{sha})" + f"{'; additional proposed: ' + '; '.join(additional) if additional else ''}.") + if not snapshot["pulls"] and not snapshot["merged"]: + lines.append("No known source Dependabot PR; exact planned producer adoption may still be required.") + if PROJECTS[repository.split("/")[1]]: + versions = [f"`{name}` `{plan['snapshots'][repository.split('/')[0] + '/' + name]['tag']}`" + for name in PROJECTS[repository.split("/")[1]]] + lines += ["", "Planned library adoption: " + ", ".join(versions) + "."] + lines += ["", dependency_links(plan, repository, routes)] + if source_links: + lines += ["", "
", "Exact source revisions", "", *source_links, "", "
"] + proposals = plan.get("bugfix_proposals", {}).get(repository, {}).get("proposals", []) + candidates = [item for item in proposals if item["classification"] in {"candidate_bugfix", "needs_review"} + and item["applicability"]["status"] != "not_applicable" + and not any("already_present" in blocker or "revert" in blocker for blocker in item["blockers"])] + if candidates: + lines += ["", "## Proposed bug-fix backports", "", + "These candidates are not included or executable. Applicability and separate scope approval are required.", ""] + for item in candidates: + pr = (f"[#{item['pr_number']}](https://github.com/{repository}/pull/{item['pr_number']})" + if item["pr_number"] else "No verified PR") + lines.append(f"- {pr}, [`{item['source_sha'][:12]}`]" + f"(https://github.com/{repository}/commit/{item['source_sha']}): " + f"{text(item['rationale'])} Applicability: {text(item['applicability']['status'])}.") + lines += ["", "## Status", "", + "Required CI and reviews must pass. Maintainer action: review this scope and linked dependencies. " + "Blockers and qualified draft-ready requests appear in comments; publication remains manual.", ""] + return "\n".join(lines) + + +def dependency_changes(files): + """Extract only explicit go.mod version-line changes as review data, never authority.""" + changes = [] + for item in files: + if item.get("filename", "").split("/")[-1] != "go.mod": + continue + before, after = {}, {} + for line in item.get("patch", "").splitlines(): + match = re.fullmatch(r"([+-])\s*(?:require\s+)?([A-Za-z0-9._~/-]+)\s+(v[^\s]+)(?:\s+// indirect)?", line) + if match: + (before if match[1] == "-" else after)[match[2]] = match[3] + for module in sorted(before.keys() & after.keys()): + if before[module] != after[module]: + change = {"module": module, "before": before[module], "after": after[module]} + if change not in changes: + changes.append(change) + return changes + + +def placeholder(plan, repository): + return (f"{marker(plan, repository)}\nRelease issue wiring is incomplete; execution is not authorized.\n" + f"Plan: `{plan['plan_id']}`.\n") + + +def checked_issue(api, actor, repository, number, expected): + issue = api.request(f"repos/{repository}/issues/{number}") + if ("pull_request" in issue or issue.get("user", {}).get("login") != actor + or issue.get("body") != expected or issue.get("state") != "open"): + raise ValueError("Release scope issue changed or has an untrusted author") + editor = api.issue_editor(repository, number) + if editor not in (None, actor): + raise ValueError("Release scope issue has an untrusted editor") + return issue + + +def ensure(api, actor, plan, root_number, routes=None, previous_hashes=None): + """Create Core, then Go, then wire all links; exact placeholders make retries safe.""" + host = plan["host"] + routes = dict(routes or {}) + validate_routes(host, routes, complete=False) + if host in routes and routes[host] != root_number: + raise ValueError("CLI release issue changed") + routes[host] = root_number + issues = {} + for repository in repositories(host): + if repository == host: + continue + matches = [issue for issue in api.pages(f"repos/{repository}/issues?state=all") + if marker(plan, repository) in (issue.get("body") or "")] + if len(matches) > 1: + raise ValueError("Duplicate repository release issues require reconciliation") + if matches: + issue = matches[0] + if repository in routes and routes[repository] != issue["number"]: + raise ValueError("Repository release route changed") + elif repository in routes: + raise ValueError("Recorded repository release issue is missing") + else: + issue = api.request(f"repos/{repository}/issues", "POST", { + "title": f"Patch release scope: {plan['month']} ({repository.split('/')[1]})", + "body": placeholder(plan, repository), + }) + if (type(issue.get("number")) is not int or issue["number"] <= 0 + or issue.get("user", {}).get("login") != actor or "pull_request" in issue + or issue.get("state") != "open"): + raise ValueError("Repository release issue creation was not confirmed") + if api.issue_editor(repository, issue["number"]) not in (None, actor): + raise ValueError("Repository release issue has an untrusted editor") + routes[repository], issues[repository] = issue["number"], issue + hashes = {} + for repository, issue in issues.items(): + expected = scope_body(plan, repository, routes) + before = issue.get("body", "") + if (before not in (expected, placeholder(plan, repository)) + and body_hash(before) != (previous_hashes or {}).get(repository)): + raise ValueError("Existing repository release scope does not match the reviewed plan") + if before != expected: + api.request(f"repos/{repository}/issues/{issue['number']}", "PATCH", {"body": expected}) + checked_issue(api, actor, repository, issue["number"], expected) + hashes[repository] = body_hash(expected) + return routes, hashes + + +def comment_once(api, actor, repository, number, identity, body): + tag = f"" + expected = f"{tag}\n{body}" + matches = [item for item in api.pages(f"repos/{repository}/issues/{number}/comments") + if tag in (item.get("body") or "") and item.get("user", {}).get("login") == actor] + if len(matches) > 1: + raise ValueError("Duplicate release notifications require reconciliation") + if matches: + if matches[0].get("body") != expected: + raise ValueError("Release notification receipt changed") + return matches[0] + item = api.request(f"repos/{repository}/issues/{number}/comments", "POST", {"body": expected}) + if (item.get("body") != expected or item.get("user", {}).get("login") != actor + or type(item.get("id")) is not int or item["id"] <= 0): + raise ValueError("Release notification was not confirmed") + return item diff --git a/.github/scripts/test_manual_release.py b/.github/scripts/test_manual_release.py new file mode 100644 index 000000000..356ea47f2 --- /dev/null +++ b/.github/scripts/test_manual_release.py @@ -0,0 +1,495 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import datetime +import gzip +import hashlib +import io +import json +import os +import pathlib +import tempfile +import unittest +import urllib.error +from unittest.mock import patch + +import monthly_release as release +import notation_release_controller as controller +import test_monthly_public_release as packages +from test_monthly_release import ENVIRONMENT, FakeGitHub, plan, pull +from test_upstream_authorization import CLI, CORE, MONTH, PR_HEAD, SOURCE, UpstreamAPI, assessed, inventory + + +class PlanReviewTests(unittest.TestCase): + def setUp(self): + self.env = patch.dict(os.environ, { + **ENVIRONMENT, "GITHUB_REPOSITORY": CLI, "GITHUB_SHA": SOURCE, + "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", "MONTHLY_PATCH_WORKER_SHA": SOURCE, + "GITHUB_RUN_ID": "1", "NOTATION_RELEASE_AUTO_APPROVAL_ENABLED": "true", + }) + self.env.start() + self.addCleanup(self.env.stop) + self.api = UpstreamAPI() + self.controller = controller.Controller(self.api, "test-actor") + self.plan = assessed(inventory()) + self.api.prs[(CORE, 1)] = pull(sha=PR_HEAD) + self.issue, self.state = self.controller.approve(self.plan, 22) + + def approve(self): + self.api.approve_comment(self.issue, self.state) + return self.controller.advance(self.issue, self.state) + + def test_assessment_creates_only_review_issue_never_execution(self): + self.assertEqual(self.state["status"], "review") + self.controller.advance(self.issue, self.state) + self.assertFalse(self.api.dispatched) + self.assertTrue(all("/issues" in path for path, _, _ in self.api.writes)) + with self.assertRaisesRegex(ValueError, "disabled"): + controller.authorize_routine(self.api, self.plan) + + def test_exact_unedited_active_maintainer_comment_dispatches(self): + issue, state = self.approve() + self.assertEqual(state["authorization"]["kind"], "maintainer") + self.assertEqual(state["authorization"]["login"], "maintainer") + self.assertEqual(len(self.api.dispatched), 1) + self.assertIn(controller.plan_comment(self.plan), issue["body"]) + + def test_unknown_bot_stale_wrong_issue_or_edited_comments_cannot_approve(self): + self.api.approve_comment(self.issue, self.state) + original = copy.deepcopy(self.api.comments[1]) + for changes in ( + {"user": {"login": "attacker", "type": "User"}}, + {"user": {"login": "maintainer", "type": "Bot"}}, + {"body": "/approve-plan " + "f" * 64}, + {"body": controller.plan_comment(self.plan) + "\nLooks good"}, + {"updated_at": "2099-01-02T00:00:00Z"}, + {"issue_url": "https://api.github.com/repos/untrusted/notation/issues/1"}, + ): + self.api.comments[1] = {**original, **changes} + self.controller.advance(self.issue, self.state) + self.assertFalse(self.api.dispatched) + + def test_close_or_checklist_is_not_approval(self): + for changes in ({"state": "closed"}, {"body": self.issue["body"] + "\n- [x] Approved"}): + changed = {**self.issue, **changes} + self.api.issues_by_repo[CLI] = [changed] + with self.assertRaises(ValueError): + self.controller.advance(changed, self.state) + self.assertFalse(self.api.dispatched) + + def test_comment_edit_deletion_and_retired_maintainer_revoke_remaining_writes(self): + issue, state = self.approve() + receipt = copy.deepcopy(self.api.comments[1]) + for changes in ({"body": "revoked"}, {"updated_at": "2099-01-02T00:00:00Z"}): + self.api.comments[1] = {**receipt, **changes} + with self.assertRaisesRegex(ValueError, "revoked"): + controller.require_plan_approval(self.api, issue, state) + self.api.comments[1] = receipt + self.api.roster = "# Repo-Level Maintainers\nReplacement (@replacement)\n" + with self.assertRaisesRegex(ValueError, "revoked"): + controller.require_plan_approval(self.api, issue, state) + self.api.comments.clear() + with self.assertRaises(KeyError): + controller.require_plan_approval(self.api, issue, state) + + def test_worker_cannot_use_unapproved_or_edited_plan(self): + os.environ.update(MONTHLY_PATCH_CONTROLLER_ISSUE=str(self.issue["number"]), + MONTHLY_PATCH_CONTROLLER_PLAN=self.plan["plan_id"], MONTHLY_PATCH_CONTROLLER_ATTEMPT="1") + with self.assertRaisesRegex(ValueError, "authorization"): + controller.worker_authorization(self.api, CORE, MONTH) + issue, state = self.approve() + self.api.comments[1]["body"] = "revoked" + with self.assertRaisesRegex(ValueError, "revoked"): + controller.worker_authorization(self.api, CORE, MONTH) + + def test_current_refs_policy_and_age_are_rechecked_before_dispatch(self): + self.api.approve_comment(self.issue, self.state) + self.api.prs[(CORE, 1)]["head"]["sha"] = "f" * 40 + with self.assertRaisesRegex(ValueError, "PR head"): + self.controller.advance(self.issue, self.state) + self.assertFalse(self.api.dispatched) + + def test_stale_plan_approval_is_not_authority_for_new_assessment(self): + self.approve() + self.state["nodes"][CORE]["status"] = "pending" + self.issue = self.controller.save(self.issue, self.state) + evidence = inventory() + evidence["collected_at"] = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=1)).isoformat() + replacement = assessed(evidence) + issue, state = self.controller.approve(replacement, 23, replan=True) + self.assertEqual(state["status"], "review") + dispatched = len(self.api.dispatched) + self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), dispatched) + self.assertNotEqual(state["plan"]["plan_id"], self.plan["plan_id"]) + + def test_stale_assessment_and_changed_policy_block_comment_approval(self): + self.api.approve_comment(self.issue, self.state) + self.state["plan"]["collected_at"] = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=2)).isoformat() + self.state["plan"]["plan_id"] = controller.digest({key: value for key, value in self.state["plan"].items() if key != "plan_id"}) + self.issue = self.controller.save(self.issue, self.state) + self.api.approve_comment(self.issue, self.state) + with self.assertRaisesRegex(ValueError, "stale"): + self.controller.advance(self.issue, self.state) + self.assertFalse(self.api.dispatched) + + def test_explicit_scope_blocker_cannot_be_overridden_by_comment(self): + self.state["review_blocker"] = "Workflow changes require a fresh configured-actor review request" + self.issue = self.controller.save(self.issue, self.state) + self.api.approve_comment(self.issue, self.state) + with self.assertRaisesRegex(ValueError, "execution is blocked"): + self.controller.advance(self.issue, self.state) + self.assertFalse(self.api.dispatched) + + def test_stable_branch_lease_and_policy_changes_block_initial_dispatch(self): + self.api.approve_comment(self.issue, self.state) + self.api.changed[(CORE, "release-1.3")] = "f" * 40 + with self.assertRaisesRegex(ValueError, "Stable branch changed"): + self.controller.advance(self.issue, self.state) + self.api.changed.clear() + self.api.policy["publication"] = "automatic" + with self.assertRaisesRegex(ValueError, "Unsupported"): + self.controller.advance(self.issue, self.state) + self.assertFalse(self.api.dispatched) + + def test_approval_is_bound_to_exact_full_readable_scope(self): + evidence = inventory() + evidence["repositories"][CORE]["pulls"][0].update( + title="Bump dependency | [injection](https://evil.invalid) @everyone", + commits=[{"sha": "e" * 40, "subject": "additional"}, {"sha": PR_HEAD, "subject": "same"}]) + item = controller.validate_assessment(evidence, { + "schema": 1, "month": MONTH, "inventory_sha256": controller.digest(evidence), + "decisions": self.plan["decisions"], + }) + rendered = controller.release_scope(item) + self.assertEqual(rendered.count(f"/pull/1)"), 1) + self.assertIn("Open PR proposed HEAD", rendered) + self.assertIn("e" * 40, rendered) + self.assertNotIn("[injection]", rendered) + self.assertNotIn("@everyone", rendered) + self.assertIn("not Dependabot source commits", rendered) + self.assertNotIn("f" * 40, rendered) + item["snapshots"][CORE]["merged"] = [{"number": 7, "commit": "d" * 40, "title": "Merged update"}] + self.assertIn("Already merged source commit to backport", controller.release_scope(item)) + + def test_missing_duplicate_or_changed_commit_inventory_cannot_be_approved(self): + for commits in ([], [{"sha": "f" * 40}], [{"sha": PR_HEAD}, {"sha": PR_HEAD}]): + evidence = inventory() + evidence["repositories"][CORE]["pulls"][0]["commits"] = commits + with self.assertRaisesRegex(ValueError, "commit list"): + assessed(evidence) + + +class DraftAPI(FakeGitHub): + def __init__(self): + super().__init__() + self.remote = None + self.assets = [] + + def pages(self, path): + if path.endswith("/assets"): + return iter(copy.deepcopy(self.assets)) + return super().pages(path) + + def request(self, path, method="GET", payload=None): + if path == f"repos/{CORE}/releases" and method == "POST": + self.writes.append((path, method, payload)) + self.remote = {**payload, "id": 44} + self.releases.append(self.remote) + return copy.deepcopy(self.remote) + if path == f"repos/{CORE}/releases/44": + if self.remote is None: + raise release.APIError("HTTP 404 release removed") + return copy.deepcopy(self.remote) + if "/issues/" in path and method == "GET": + return next(issue for issue in self.issues if issue["number"] == int(path.rsplit("/", 1)[1])) + return super().request(path, method, payload) + + def upload(self, command): + if not command[4].startswith("https://uploads.github.com/"): + raise AssertionError("Expected the exact trusted draft upload endpoint") + path = pathlib.Path(command[-1]) + content = path.read_bytes() + item = {"id": len(self.assets) + 100, "name": path.name, "size": len(content), + "digest": "sha256:" + hashlib.sha256(content).hexdigest()} + self.assets.append(item) + return json.dumps(item) + + +class DraftReleaseTests(unittest.TestCase): + def setUp(self): + self.environment = patch.dict(os.environ, { + **ENVIRONMENT, "GITHUB_RUN_ID": "123", "GITHUB_REPOSITORY": CORE, + "MONTHLY_PATCH_WORKER_SHA": SOURCE, + }) + self.environment.start() + self.addCleanup(self.environment.stop) + for name in ("coordinator_authorization", "assert_tag"): + guard = patch.object(release, name, return_value=None) + guard.start() + self.addCleanup(guard.stop) + self.api = DraftAPI() + self.state = plan(status="tagged") + self.state.update(controller_issue=10, controller_plan_id="a" * 64, + merged=[{"number": 1, "commit": PR_HEAD, "title": "Dependency update"}]) + validate = patch("monthly_release_pr.validate") + validate.start() + self.addCleanup(validate.stop) + release.Cycle(self.api, CORE, MONTH, "execute").save(self.state) + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = pathlib.Path(directory.name) + for name in release.asset_names("notation-core-go", self.state["tag"]): + (self.directory / name).write_bytes(("qualified bytes for " + name).encode()) + + def stage(self): + with patch.object(release, "run", side_effect=self.api.upload): + return release.publish(self.api, self.state, self.directory) + + def prepare(self): + return release.prepare(self.api, CORE, "execute", MONTH, self.directory, self.directory, True) + + def qualify(self): + return release.complete(self.api, self.stage()) + + def test_true_draft_stable_tag_and_exact_recorded_assets(self): + draft = self.stage() + self.assertEqual(draft["status"], "draft") + self.assertEqual(draft["release_id"], 44) + self.assertTrue(self.api.remote["draft"]) + self.assertFalse(self.api.remote["prerelease"]) + self.assertEqual(len(draft["assets"]), 2) + self.assertTrue(all(item["id"] >= 100 for item in draft["assets"])) + self.assertFalse(any("Qualified draft release review" in issue["body"] for issue in self.api.issues)) + self.assertEqual(self.prepare()["action"], "draft-check") + + def test_only_successful_draft_checks_open_review_issue_then_wait_for_human(self): + result = self.qualify() + self.assertEqual(result["status"], "awaiting-publication") + review = release.check_draft_review(self.api, result) + self.assertIn("Publish release", review["body"]) + self.assertIn("already public", review["body"]) + self.assertIn(PR_HEAD, review["body"]) + self.assertIn("Dependabot source", review["body"]) + self.assertEqual(self.prepare()["action"], "waiting") + self.assertTrue(self.api.remote["draft"]) + self.assertFalse(any(path.endswith("/dispatches") for path, _, _ in self.api.writes)) + + def test_human_publication_requires_same_identity_then_anonymous_gate_before_complete(self): + review = self.qualify() + self.api.remote["draft"] = False + result = self.prepare() + self.assertEqual(result["action"], "verify") + self.assertEqual(result["release_id"], review["release_id"]) + self.assertEqual(result["status"], "verifying") + done = release.complete(self.api, result) + self.assertEqual(done["status"], "published") + self.assertFalse(any(method == "PATCH" and "/releases/" in path for path, method, _ in self.api.writes)) + + def test_published_before_draft_quality_checks_cannot_become_qualified(self): + draft = self.stage() + self.api.remote["draft"] = False + with self.assertRaisesRegex(ValueError, "visibility"): + release.complete(self.api, draft) + with self.assertRaisesRegex(ValueError, "visibility"): + self.prepare() + self.assertEqual(len(self.api.issues), 1) + + def test_changed_asset_id_bytes_size_and_extra_assets_block_publication_observation(self): + self.qualify() + self.api.remote["draft"] = False + original = copy.deepcopy(self.api.assets) + for changes in ({"id": 999}, {"digest": "sha256:" + "f" * 64}, {"size": 1}): + self.api.assets[0] = {**original[0], **changes} + with self.assertRaisesRegex(ValueError, "integrity"): + self.prepare() + self.api.assets = original + [original[0]] + with self.assertRaisesRegex(ValueError, "assets differ"): + self.prepare() + + def test_replaced_release_tag_or_prerelease_is_rejected(self): + self.qualify() + self.api.remote["draft"] = False + original = copy.deepcopy(self.api.remote) + for changes in ({"id": 45}, {"tag_name": "v1.3.2"}, {"prerelease": True}): + self.api.remote = {**original, **changes} + with self.assertRaises(ValueError): + self.prepare() + self.api.remote = None + with self.assertRaises(release.APIError): + self.prepare() + + def test_review_issue_mutation_cannot_authorize_progress(self): + review = self.qualify() + issue = next(item for item in self.api.issues if item["number"] == review["draft_review_issue"]) + issue["body"] += "\nForged approval" + with self.assertRaisesRegex(ValueError, "review issue"): + self.prepare() + + def test_unrecorded_existing_release_is_not_adopted_even_if_draft(self): + self.api.releases.append({"tag_name": self.state["tag"], "draft": True}) + with self.assertRaisesRegex(ValueError, "Unrecorded existing"): + self.stage() + self.assertIsNone(self.api.remote) + + def test_retry_uploads_missing_assets_but_never_replaces_or_rebuilds_bytes(self): + original = self.api.upload + calls = [] + def fail_second(command): + calls.append(command) + if len(calls) == 2: + raise release.APIError("HTTP 503 upload failed") + return original(command) + with patch.object(release, "run", side_effect=fail_second), self.assertRaises(release.APIError): + release.publish(self.api, self.state, self.directory) + saved = release.Cycle(self.api, CORE, MONTH, "execute").state + self.assertEqual(saved["status"], "staging") + self.assertEqual(saved["release_id"], 44) + path = next(self.directory.iterdir()) + content = path.read_bytes() + path.write_bytes(b"different build") + with self.assertRaisesRegex(ValueError, "originally staged"): + release.publish(self.api, saved, self.directory) + path.write_bytes(content) + with patch.object(release, "run", side_effect=original): + resumed = release.publish(self.api, saved, self.directory) + self.assertEqual(resumed["release_id"], 44) + self.assertEqual(len(self.api.assets), 2) + self.assertEqual(sum(method == "POST" and path.endswith("/releases") for path, method, _ in self.api.writes), 1) + + def test_review_issue_retry_reuses_original_receipt_and_issue(self): + draft = self.stage() + save = release.Cycle.save + def fail_final(cycle, state): + if state["status"] == "awaiting-publication": + raise release.APIError("HTTP 503 state save failed") + return save(cycle, state) + with patch.object(release.Cycle, "save", fail_final), self.assertRaises(release.APIError): + release.complete(self.api, draft) + saved = release.Cycle(self.api, CORE, MONTH, "execute").state + self.assertEqual(saved["status"], "draft") + self.assertEqual(len(self.api.issues), 2) + with patch.dict(os.environ, {"GITHUB_RUN_ID": "124"}): + resumed = release.complete(self.api, saved) + self.assertEqual(len(self.api.issues), 2) + self.assertEqual(resumed["draft_verification"]["run_id"], 123) + + def test_native_jobs_cannot_access_release_secrets_and_never_publish(self): + root = pathlib.Path(__file__).resolve().parents[1] + workflow = (root / "workflows/monthly-patch-worker.yml").read_text() + native = workflow.split(" verify:\n", 1)[1].split(" complete:\n", 1)[0] + self.assertNotIn("secrets.", native) + self.assertIn("GH_TOKEN: ${{ github.token }}", native) + self.assertIn("needs.verify.result == 'success'", workflow) + self.assertNotIn("draft: false", workflow) + self.assertNotIn("--draft=false", workflow) + self.assertIn("inputs.mode == 'execute'", workflow) + + def test_draft_manifest_cannot_replace_candidate_scope_during_review(self): + draft = self.stage() + forged = copy.deepcopy(draft) + forged["merged"].append({"number": 999, "commit": "f" * 40}) + result = release.complete(self.api, forged) + body = release.check_draft_review(self.api, result)["body"] + self.assertNotIn("/pull/999", body) + self.assertNotIn("f" * 40, body) + + def test_unqualified_public_manifest_is_rejected(self): + with self.assertRaisesRegex(ValueError, "qualified reviewed"): + release.check_public_assets(self.api, self.state) + + +class DraftDownloadTests(unittest.TestCase): + def test_direct_download_uses_recorded_id_and_read_only_token(self): + response = io.BytesIO(b"qualified") + with patch.dict(os.environ, {"GH_TOKEN": "readonly"}), patch.object(packages.urllib.request, "build_opener") as opener: + opener.return_value.open.return_value = response + self.assertEqual(packages.download_draft(CORE, 100), b"qualified") + request = opener.return_value.open.call_args.args[0] + self.assertEqual(request.full_url, f"https://api.github.com/repos/{CORE}/releases/assets/100") + self.assertEqual(request.headers["Authorization"], "Bearer readonly") + + def test_redirect_download_never_forwards_authorization(self): + redirect = urllib.error.HTTPError("https://api.github.com", 302, "redirect", + {"Location": "https://release-assets.githubusercontent.com/signed"}, None) + with patch.dict(os.environ, {"GH_TOKEN": "readonly"}), \ + patch.object(packages.urllib.request, "build_opener") as opener, \ + patch.object(packages.urllib.request, "urlopen", return_value=io.BytesIO(b"bytes")) as download: + opener.return_value.open.side_effect = redirect + self.assertEqual(packages.download_draft(CORE, 100), b"bytes") + self.assertEqual(download.call_args.args, ("https://release-assets.githubusercontent.com/signed",)) + self.assertNotIn("readonly", str(download.call_args)) + + def test_denied_missing_network_and_untrusted_redirects_fail_closed(self): + for code in (401, 403, 404, 500): + error = urllib.error.HTTPError("https://api.github.com", code, "denied", {}, None) + with patch.dict(os.environ, {"GH_TOKEN": "readonly"}), \ + patch.object(packages.urllib.request, "build_opener") as opener, \ + patch.object(packages.urllib.request, "urlopen") as fallback: + opener.return_value.open.side_effect = error + with self.assertRaisesRegex(ValueError, "no write-token fallback"): + packages.download_draft(CORE, 100) + fallback.assert_not_called() + for location in ("http://release-assets.githubusercontent.com/x", "https://evil.invalid/x"): + with patch.dict(os.environ, {"GH_TOKEN": "readonly"}), \ + patch.object(packages.urllib.request, "build_opener") as opener: + opener.return_value.open.side_effect = urllib.error.HTTPError("", 302, "", {"Location": location}, None) + with self.assertRaisesRegex(ValueError, "Untrusted"): + packages.download_draft(CORE, 100) + + def test_draft_native_gate_hashes_downloaded_bytes_and_writes_evidence(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + source = root / "source" + source.mkdir() + candidate = plan(status="draft") + candidate["release_id"] = 44 + names = release.asset_names("notation-core-go", candidate["tag"]) + content = {name: gzip.compress(b"source") if name.endswith(".tar.gz") else b"checksums" for name in names} + candidate["assets"] = [{"id": index + 100, "name": name, "size": len(data), + "sha256": hashlib.sha256(data).hexdigest()} for index, (name, data) in enumerate(content.items())] + by_id = {item["id"]: content[item["name"]] for item in candidate["assets"]} + archive = next(name for name in names if name.endswith(".tar.gz")) + with patch.object(packages, "assert_tag"), patch.object(packages, "check_release_assets"), \ + patch.object(packages, "git", return_value=candidate["commit"]), \ + patch.object(packages, "download_draft", side_effect=lambda repository, identifier: by_id[identifier]), \ + patch.object(packages, "verify_checksums", return_value=[archive]), \ + patch.object(packages, "library_source_archive", return_value=b"source"): + packages.verify(candidate, source, "linux_amd64", root / "passed") + evidence = json.loads((root / "passed/verified.json").read_text()) + self.assertTrue(evidence["draft_downloads"]) + self.assertFalse(evidence["public_downloads"]) + by_id[100] = b"tampered" + with self.assertRaisesRegex(ValueError, "Downloaded bytes differ"): + packages.verify(candidate, source, "linux_amd64", root / "failed") + self.assertFalse((root / "failed/verified.json").exists()) + by_id[100] = content[candidate["assets"][0]["name"]] + candidate["status"] = "verifying" + def anonymous(url, timeout): + self.assertIsInstance(url, str) + self.assertTrue(url.startswith(f"https://github.com/{CORE}/releases/download/")) + return io.BytesIO(content[url.rsplit("/", 1)[1]]) + with patch.object(packages, "assert_tag"), patch.object(packages, "check_public_assets"), \ + patch.object(packages, "git", return_value=candidate["commit"]), \ + patch.object(packages, "download_draft") as authenticated, \ + patch.object(packages.urllib.request, "urlopen", side_effect=anonymous), \ + patch.object(packages, "verify_checksums", return_value=[archive]), \ + patch.object(packages, "library_source_archive", return_value=b"source"): + packages.verify(candidate, source, "linux_amd64", root / "public") + authenticated.assert_not_called() + self.assertTrue(json.loads((root / "public/verified.json").read_text())["public_downloads"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_monthly_public_release.py b/.github/scripts/test_monthly_public_release.py new file mode 100644 index 000000000..33590776d --- /dev/null +++ b/.github/scripts/test_monthly_public_release.py @@ -0,0 +1,123 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Download public assets, verify provenance, and test the actual shipped package.""" + +import argparse +import gzip +import hashlib +import json +import os +import pathlib +import urllib.error +import urllib.parse +import urllib.request + +from monthly_cli_smoke import smoke +from monthly_release import GitHub, assert_tag, check_public_assets, check_release_assets, git, validate_plan +from monthly_release_checks import ( + asset_names, checked, extract_binary, library_source_archive, packaged_e2e, scan, + verify_checksums, verify_metadata, +) + + +class NoCredentialRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, newurl): + return None + + +def download_draft(repository, identifier): + token = os.environ.get("GH_TOKEN", "") + if not token or type(identifier) is not int or identifier <= 0: + raise ValueError("Draft asset download requires its recorded ID and read-only workflow token") + request = urllib.request.Request( + f"https://api.github.com/repos/{repository}/releases/assets/{identifier}", + headers={"Authorization": f"Bearer {token}", "Accept": "application/octet-stream", + "X-GitHub-Api-Version": "2022-11-28"}, + ) + try: + with urllib.request.build_opener(NoCredentialRedirect()).open(request, timeout=120) as response: + return response.read() + except urllib.error.HTTPError as error: + if error.code != 302: + raise ValueError(f"Read-only draft asset access failed (HTTP {error.code}); no write-token fallback") from None + location = error.headers.get("Location", "") + target = urllib.parse.urlsplit(location) + if (target.scheme != "https" or target.hostname != "release-assets.githubusercontent.com" + or target.username or target.password or target.port not in {None, 443}): + raise ValueError("Untrusted draft asset redirect") from None + # Signed storage redirects receive no Authorization header or token. + with urllib.request.urlopen(location, timeout=120) as response: + return response.read() + + +def verify(plan, source, platform, evidence): + validate_plan(plan) + draft = plan["status"] == "draft" + if plan["status"] not in {"draft", "verifying"}: + raise ValueError("Only an awaiting-verification release can complete this gate") + source, evidence = source.resolve(), evidence.resolve() + evidence.mkdir(parents=True, exist_ok=True) + api = GitHub() + assert_tag(api, plan) + check_release_assets(api, plan, True) if draft else check_public_assets(api, plan) + if git(["rev-parse", "HEAD"], source) != plan["commit"]: + raise ValueError("Downloaded release tests must use its qualified source fixtures") + project = plan["repository"].split("/")[1] + names = asset_names(project, plan["tag"]) + wanted = [name for name in names if name.endswith("checksums.txt") or project != "notation" or f"_{platform}." in name] + recorded = {item["name"]: item for item in plan["assets"]} + if set(recorded) != set(names): + raise ValueError("Publication manifest has an unexpected asset set") + download = evidence / "download" + download.mkdir(exist_ok=True) + for name in wanted: + expected = recorded[name] + if draft: + content = download_draft(plan["repository"], expected["id"]) + else: + url = f"https://github.com/{plan['repository']}/releases/download/{plan['tag']}/{name}" + # No credentials: this gate also establishes public download availability. + with urllib.request.urlopen(url, timeout=120) as response: + content = response.read() + if len(content) != expected["size"] or hashlib.sha256(content).hexdigest() != expected["sha256"]: + raise ValueError(f"Downloaded bytes differ from qualified asset: {name}") + (download / name).write_bytes(content) + archives = verify_checksums(download, project, plan["tag"], platform if project == "notation" else None) + if project == "notation": + binary = extract_binary(download / archives[0], evidence / ("notation.exe" if platform.startswith("windows") else "notation")) + metadata = checked(["go", "version", "-m", binary], evidence / "metadata.txt") + verify_metadata(metadata, plan["tag"], plan["commit"], platform) + scan(["govulncheck", "-mode=binary", "-format=json", str(binary)], evidence / "native-scan", "binary") + smoke(binary, source, plan["tag"], plan["commit"], evidence / "functional") + if platform == "linux_amd64": + packaged_e2e(source, binary, evidence / "packaged-e2e.log") + else: + expected = library_source_archive(source, project, plan["tag"], plan["commit"]) + if gzip.decompress((download / archives[0]).read_bytes()) != expected: + raise ValueError("Public library archive does not match the qualified source") + (evidence / "verified.json").write_text(json.dumps({ + "repository": plan["repository"], "tag": plan["tag"], + "commit": plan["commit"], "platform": platform, "assets": wanted, + "release_id": plan["release_id"], "public_downloads": not draft, "draft_downloads": draft, "passed": True, + }, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", type=pathlib.Path, required=True) + parser.add_argument("--source", type=pathlib.Path, required=True) + parser.add_argument("--platform", required=True) + parser.add_argument("--evidence", type=pathlib.Path, required=True) + args = parser.parse_args() + verify(json.loads(args.plan.read_text(encoding="utf-8")), args.source, args.platform, args.evidence) diff --git a/.github/scripts/test_monthly_release.py b/.github/scripts/test_monthly_release.py new file mode 100644 index 000000000..e0e49e654 --- /dev/null +++ b/.github/scripts/test_monthly_release.py @@ -0,0 +1,698 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import copy +import json +import os +import pathlib +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import monthly_release as release + + +REPO = "notaryproject/notation-core-go" +COMMIT, PREVIOUS = "a" * 40, "b" * 40 +ENVIRONMENT = { + "GITHUB_REF": "refs/heads/main", "MONTHLY_PATCH_ENABLED": "true", + "MONTHLY_PATCH_TOKEN_READY": "true", + "MONTHLY_PATCH_SIGNING_KEY": "temporary-test-key", "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", + "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", "MONTHLY_PATCH_ACTOR": "test-actor", +} + + +def plan(repository=REPO, mode="execute", month="2026-10", status="ready"): + return { + "schema": 1, "repository": repository, "mode": mode, "month": month, + "baseline": "v1.3.0", "baseline_published": "2026-01-01T00:00:00Z", + "branch": "release-1.3" if mode == "execute" else "monthly-patch-test-release-1.3", + "main": "main" if mode == "execute" else "monthly-patch-test-main", + "tag": "v1.3.1" if mode == "execute" else f"v1.3.4-monthly-test.{month.replace('-', '')}", + "commit": COMMIT, "previous_head": PREVIOUS, "status": status, "merged": [], + "started_at": "2026-10-01T09:00:00+00:00", + } + + +def body(state): + return release.plan_marker(state) + "\n```json\n" + json.dumps(state) + "\n```" + + +def pull(number=1, sha=COMMIT, base="main"): + return { + "number": number, "state": "open", "draft": False, + "user": {"login": "dependabot[bot]", "type": "Bot"}, + "base": {"ref": base}, "head": {"sha": sha, "repo": {"full_name": REPO}}, + } + + +def checks(sha=COMMIT): + return { + "headRefOid": sha, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "reviewDecision": "APPROVED", "statusCheckRollup": [ + {"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "SUCCESS"}], + } + + +class FakeGitHub: + def __init__(self): + self.issues, self.writes, self.open_pulls, self.closed_pulls = [], [], [], [] + self.head = PREVIOUS + self.releases = [{"tag_name": "v1.3.0", "published_at": "2026-01-01T00:00:00Z", + "draft": False, "prerelease": False}] + self.files = [{"filename": "go.mod"}] + self.check_result = checks() + self.releases_by_repo, self.issues_by_repo, self.manifests = {}, {}, {} + + def pages(self, path): + if "/issues?" in path: + issues = self.issues_by_repo.get("/".join(path.split("/")[1:3]), self.issues) + return iter(issues if "state=all" in path else [item for item in issues if item.get("state") != "closed"]) + if path.endswith("/releases"): + return iter(self.releases_by_repo.get("/".join(path.split("/")[1:3]), self.releases)) + if path.endswith("/tags"): + return iter([{"name": "v1.3.3-trial.4"}, {"name": "v9.0.0"}]) + if path.endswith("/files"): + return iter(self.files) + if path.endswith("/ssh_signing_keys"): + return iter([{"key": self.public_key}]) + raise AssertionError(path) + + def issue_editor(self, repository, number): + return "test-actor" + + def request(self, path, method="GET", payload=None): + if method != "GET": + self.writes.append((path, method, payload)) + if "/issues" in path: + issues = self.issues_by_repo.get("/".join(path.split("/")[1:3]), self.issues) + number = int(path.rsplit("/", 1)[1]) if method == "PATCH" else len(issues) + 1 + issue = { + "number": number, "body": payload["body"], "state": payload.get("state", "open"), + "user": {"login": "test-actor"}, + "created_at": "2026-01-01T00:00:00Z", + } + issues[:] = [item for item in issues if item.get("number", 1) != number] + [issue] + return issue + if path.endswith("/dispatches"): + return None + if path.endswith("/merge"): + return {"merged": True, "sha": COMMIT} + raise AssertionError(path) + if path.startswith("repos/") and len(path.split("/")) == 3: + return {"default_branch": "main", "fork": path.startswith("repos/yizha1")} + if "/branches/" in path: + return {"commit": {"sha": self.head}} + if "/pulls/" in path: + return next(item for item in self.open_pulls if str(item["number"]) == path.split("/")[-1]) + if "/releases/tags/" in path: + repository = "/".join(path.split("/")[1:3]) + return next(item for item in self.releases_by_repo.get(repository, self.releases) if item["tag_name"] == path.rsplit("/", 1)[1]) + raise AssertionError(path) + + def optional(self, path): + if "/git/ref/tags/" in path or "/contents/" in path: + return None + return self.request(path) + + def pulls(self, repository, branch, state): + return self.open_pulls if state == "open" else self.closed_pulls + + def checks(self, repository, number): + return self.check_result + + def manifest(self, repository, ref, directory): + return self.manifests.get((repository, ref, directory), { + "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} + for name in release.PROJECTS[repository.split("/")[1]]], + }) + + +class MonthlyReleaseTests(unittest.TestCase): + def setUp(self): + self.environment = patch.dict(os.environ, ENVIRONMENT) + self.environment.start() + self.addCleanup(self.environment.stop) + self.authorization = patch.object(release, "coordinator_authorization", return_value=None) + self.authorization.start() + self.addCleanup(self.authorization.stop) + + def test_stable_selection_uses_semver_and_excludes_trials(self): + items = [{"tag_name": tag, "draft": draft, "prerelease": pre} for tag, draft, pre in [ + ("v1.9.9", False, False), ("v1.10.0", False, False), + ("v2.0.0", True, False), ("v3.0.0-test", False, True), ("v01.11.0", False, False)]] + self.assertEqual(release.latest_release(items)["tag_name"], "v1.10.0") + with self.assertRaises(ValueError): + release.latest_release([]) + + + def test_policy_requires_identity_opt_in_trusted_ref_and_all_credentials(self): + release.policy(REPO, "execute", ENVIRONMENT, {"default_branch": "main"}) + for name in ENVIRONMENT: + environment = {**ENVIRONMENT, name: ""} + with self.subTest(name=name), self.assertRaises(ValueError): + release.policy(REPO, "execute", environment, {"default_branch": "main"}) + for repository, mode, metadata in [ + ("yizha1/notation", "execute", {"default_branch": "main"}), + ("notaryproject/notation", "rehearse", {"fork": True}), + ("yizha1/notation", "rehearse", {"fork": False}), + ("other/product", "dry-run", {}), + ]: + with self.subTest(repository=repository, mode=mode), self.assertRaises(ValueError): + release.policy(repository, mode, ENVIRONMENT, metadata) + release.policy(REPO, "dry-run", {}, {}) + + def test_plan_identity_namespace_and_signature_sha_guards(self): + release.validate_plan(plan()) + for name, value in [ + ("repository", "other/product"), ("schema", 2), ("baseline", "v1.3.0-trial"), + ("branch", "main"), ("main", "release-1.3"), ("tag", "v1.3.2"), + ("commit", "a\ninjected"), ("previous_head", "bad"), ("status", "unknown"), + ]: + with self.subTest(name=name), self.assertRaises((ValueError, KeyError)): + release.validate_plan({**plan(), name: value}) + malformed = body(plan()).replace('"schema": 1', '"schema": 1, "schema": 1') + with self.assertRaisesRegex(ValueError, "Duplicate"): + release.decode_state(malformed, REPO, "2026-10", "execute") + with self.assertRaises(ValueError): + release.decode_state(body(plan()) + "\n```json\n{}\n```", REPO, "2026-10", "execute") + + def test_untrusted_or_duplicate_cycle_issue_cannot_control_releases(self): + api = FakeGitHub() + api.issues = [{"body": body(plan()), "user": {"login": "attacker"}}] + self.assertIsNone(release.Cycle(api, REPO, "2026-10", "execute").state) + api.issues[0]["user"]["login"] = "test-actor" + api.issues *= 2 + with self.assertRaisesRegex(ValueError, "Duplicate"): + release.Cycle(api, REPO, "2026-10", "execute") + self.assertFalse(api.writes) + + def test_unverified_older_month_is_resumed_not_accepted_as_new_baseline(self): + api = FakeGitHub() + api.issues = [{"body": body(plan(month="2026-09", status="verifying")), "user": {"login": "test-actor"}, "state": "open"}] + self.assertEqual(release.resume_cycle(api, REPO, "2026-10", "execute"), ("2026-09", "monthly")) + api.issues.append({"body": body(plan()), "user": {"login": "test-actor"}}) + with self.assertRaisesRegex(ValueError, "Multiple active"): + release.resume_cycle(api, REPO, "2026-10", "execute") + + def test_only_actual_dependabot_targets_qualify(self): + self.assertTrue(release.dependabot(pull(), "main")) + for item in [ + {**pull(), "user": {"login": "dependabot[bot]", "type": "User"}}, + {**pull(), "user": {"login": "person", "type": "Bot"}}, + pull(base="feature"), + ]: + self.assertFalse(release.dependabot(item, "main")) + + def test_checks_reject_changed_head_pending_failed_missing_and_missing_review(self): + self.assertEqual(release.checks_ready(pull(), checks()), (True, "")) + for key, value in [ + ("headRefOid", PREVIOUS), ("mergeable", "UNKNOWN"), + ("mergeStateStatus", "BLOCKED"), ("reviewDecision", "REVIEW_REQUIRED"), + ("statusCheckRollup", []), + ("statusCheckRollup", [{"status": "IN_PROGRESS", "conclusion": None}]), + ("statusCheckRollup", [{"status": "COMPLETED", "conclusion": "FAILURE"}]), + ("statusCheckRollup", [{"status": "COMPLETED", "conclusion": "SKIPPED"}]), + ("statusCheckRollup", [{"__typename": "StatusContext", "state": "PENDING"}]), + ]: + with self.subTest(key=key, value=value): + self.assertFalse(release.checks_ready(pull(), {**checks(), key: value})[0]) + self.assertFalse(release.checks_ready({**pull(), "draft": True}, checks())[0]) + + def test_dependency_only_file_allowlist_includes_rename_source(self): + for path in ("go.mod", "go.sum", "test/e2e/go.mod", "test/e2e/plugin/go.sum", ".github/workflows/build.yaml"): + self.assertTrue(release.dependency_file(path)) + for path in ("cmd/notation/sign.go", "README.md", ".github/scripts/monthly_release.py", ".github/workflows/nested/build.yml"): + self.assertFalse(release.dependency_file(path)) + api = FakeGitHub() + api.files = [{"filename": "go.mod", "previous_filename": "sensitive.go"}] + with self.assertRaises(ValueError): + release.check_files(api, REPO, 1) + + def test_canonical_manifests_never_accept_trial_replacements(self): + manifest = {"Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.1"}]} + module = manifest["Require"][0]["Path"] + self.assertEqual(release.requirement(manifest, module, REPO, "execute"), "v1.3.1") + replacement = {"Old": {"Path": module}, "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.4-monthly-test.202610"}} + manifest["Replace"] = [replacement] + with self.assertRaises(ValueError): + release.requirement(manifest, module, REPO, "execute") + with self.assertRaises(ValueError): + release.requirement(manifest, module, "wrong/notation-core-go", "rehearse") + with self.assertRaisesRegex(ValueError, "explicit dependency"): + release.requirement({"Require": []}, module, REPO, "execute") + + def test_inventory_orders_merges_chronologically_not_by_pr_number(self): + api = FakeGitHub() + api.closed_pulls = [ + {**pull(1), "merged_at": "2026-09-03T00:00:00Z", "merge_commit_sha": COMMIT}, + {**pull(2), "merged_at": "2026-09-02T00:00:00Z", "merge_commit_sha": PREVIOUS}, + {**pull(3), "merged_at": None}, + ] + self.assertEqual([item["number"] for item in release.inventory(api, REPO, "main")], [2, 1]) + + def test_reassessment_retains_recorded_producer_merge_without_reauthorizing_old_open_prs(self): + api = FakeGitHub() + producer = {**pull(14), "user": {"login": "test-actor"}, "merged_at": "2026-10-01T00:00:00Z", + "merge_commit_sha": COMMIT} + api.closed_pulls = [producer] + authorization = {"snapshot": {"pulls": [], "merged": []}} + recorded = [{"number": 14, "commit": COMMIT}] + with patch("notation_release_controller.producer_pull", return_value=False): + self.assertEqual(release.inventory(api, REPO, "main", authorization, recorded), + [{"number": 14, "commit": COMMIT, "merged_at": producer["merged_at"]}]) + self.assertEqual(release.inventory(api, REPO, "main", authorization), []) + api.closed_pulls = [{**producer, "merged_at": None}] + self.assertEqual(release.inventory(api, REPO, "main", authorization, recorded), []) + api.closed_pulls = [{**producer, "merge_commit_sha": PREVIOUS}] + with self.assertRaisesRegex(ValueError, "Worker-approved dependency merge changed"): + release.inventory(api, REPO, "main", authorization, recorded) + + def test_dry_run_existing_ready_tag_recovery_is_strictly_read_only(self): + api = FakeGitHub() + api.issues = [{"body": body(plan()), "user": {"login": "test-actor"}}] + with tempfile.TemporaryDirectory() as temporary: + result = release.prepare(api, REPO, "dry-run", "2026-10", pathlib.Path(temporary), temporary, True) + self.assertFalse(result["writes"]) + self.assertEqual(result["action"], "preview") + self.assertFalse(api.writes) + + def test_dry_run_pr_preview_never_merges(self): + api = FakeGitHub() + api.open_pulls = [pull()] + with tempfile.TemporaryDirectory() as temporary: + result = release.prepare(api, REPO, "dry-run", "2026-10", pathlib.Path(temporary), temporary, True) + self.assertEqual(result["pulls"], [{"number": 1, "ready": True, "reason": ""}]) + self.assertFalse(api.writes) + + def test_blocked_earlier_pr_does_not_prevent_a_checked_cve_fix_merge(self): + for failure in ("failed", "pending", "missing"): + with self.subTest(failure=failure): + api = FakeGitHub() + head = "c" * 40 + api.open_pulls = [pull(3), pull(6, sha=head)] + blocked = checks() + if failure == "failed": + blocked["statusCheckRollup"][0]["conclusion"] = "FAILURE" + elif failure == "pending": + blocked["statusCheckRollup"][0]["status"] = "IN_PROGRESS" + else: + blocked["statusCheckRollup"] = [] + with patch.object(api, "checks", side_effect=lambda _, number: blocked if number == 3 else checks(head)), \ + patch.object(release, "signing_environment", return_value=dict(os.environ)), \ + patch.object(release, "git") as candidate_git: + result = release.prepare(api, REPO, "execute", "2026-10", ".", ".", True) + self.assertEqual(result["action"], "waiting") + self.assertIn("Dependabot #3:", result["reason"]) + self.assertEqual(result["merged"], [{"number": 6, "commit": COMMIT}]) + merges = [(path, payload) for path, method, payload in api.writes if method == "PUT"] + self.assertEqual(merges, [(f"repos/{REPO}/pulls/6/merge", {"sha": head, "merge_method": "squash"})]) + candidate_git.assert_not_called() + api.open_pulls = [pull(3)] + api.check_result = blocked + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + resumed = release.prepare(api, REPO, "execute", "2026-10", ".", ".", False, True) + self.assertEqual(resumed["action"], "waiting") + self.assertEqual(resumed["merged"], result["merged"]) + self.assertEqual(sum(method == "PUT" for _, method, _ in api.writes), 1) + self.assertEqual(len(api.issues), 1) + + def test_all_blocked_prs_are_reported_without_merging_or_backporting(self): + api = FakeGitHub() + api.open_pulls = [pull(3), pull(6)] + api.check_result["statusCheckRollup"][0]["conclusion"] = "FAILURE" + with patch.object(release, "signing_environment", return_value=dict(os.environ)), \ + patch.object(release, "git") as candidate_git: + result = release.prepare(api, REPO, "execute", "2026-10", ".", ".", True) + self.assertEqual(result["action"], "waiting") + self.assertEqual(result["reason"], "Dependabot #3: A check failed; Dependabot #6: A check failed") + self.assertFalse(result["merged"]) + self.assertFalse(any(method == "PUT" for _, method, _ in api.writes)) + candidate_git.assert_not_called() + + def test_changed_check_head_never_qualifies_a_later_merge(self): + api = FakeGitHub() + api.open_pulls = [pull(3), pull(6, sha="c" * 40)] + api.check_result["statusCheckRollup"][0]["conclusion"] = "FAILURE" + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + result = release.prepare(api, REPO, "execute", "2026-10", ".", ".", True) + self.assertEqual(result["action"], "waiting") + self.assertIn("Dependabot #6: Draft or changed pull-request head", result["reason"]) + self.assertFalse(any(method == "PUT" for _, method, _ in api.writes)) + + def test_automatic_resumption_does_not_start_an_unscheduled_cycle(self): + api = FakeGitHub() + result = release.prepare(api, REPO, "execute", "2026-10", ".", ".", False) + self.assertEqual(result["action"], "idle") + self.assertFalse(api.writes) + + def test_empty_cycle_is_closed_as_skipped(self): + api = FakeGitHub() + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + result = release.prepare(api, REPO, "execute", "2026-10", ".", ".", True) + self.assertEqual(result["status"], "skipped") + self.assertEqual(api.issues[0]["state"], "closed") + self.assertFalse(any(method == "PUT" for _, method, _ in api.writes)) + + def test_unfinished_producer_without_new_public_release_does_not_block_consumer(self): + for status in ("waiting", "failed", "verifying"): + api = FakeGitHub() + api.issues = [{"body": body(plan(status=status)), "user": {"login": "test-actor"}}] + ready = release.producer_versions(api, "notaryproject/notation-go", "execute") + self.assertEqual(ready["notation-core-go"]["version"], "v1.3.0") + + def test_consumer_does_not_require_any_same_month_producer_cycle(self): + api = FakeGitHub() + ready = release.producer_versions(api, "notaryproject/notation-go", "execute") + self.assertEqual(ready["notation-core-go"]["version"], "v1.3.0") + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + result = release.prepare(api, "notaryproject/notation-go", "execute", "2026-10", ".", ".", True) + self.assertEqual(result["status"], "skipped") + + def test_unverified_automated_public_release_is_not_consumed_yet(self): + api = FakeGitHub() + state = plan(status="verifying") + api.issues = [{"body": body(state), "user": {"login": "test-actor"}}] + api.releases.append({"tag_name": "v1.3.1", "draft": False, "prerelease": False, "body": body(state)}) + self.assertEqual(release.producer_versions(api, "notaryproject/notation-go", "execute")["notation-core-go"]["version"], "v1.3.0") + state["status"] = "published" + api.issues[0]["body"] = body(state) + with patch.object(release, "assert_tag", return_value=COMMIT): + self.assertEqual(release.producer_versions(api, "notaryproject/notation-go", "execute")["notation-core-go"]["version"], "v1.3.1") + + def test_missing_new_core_dependabot_pr_waits_without_publishing_old_version(self): + api = FakeGitHub() + api.releases_by_repo[REPO] = [{"tag_name": "v1.3.1", "draft": False, "prerelease": False}] + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + result = release.prepare(api, "notaryproject/notation-go", "execute", "2026-10", ".", ".", True) + self.assertEqual(result["status"], "waiting") + self.assertIn("Dependabot", result["reason"]) + self.assertIn("v1.3.1", result["reason"]) + self.assertFalse(any(path.endswith("/merge") for path, _, _ in api.writes)) + + def test_late_core_release_opens_distinct_cycle_after_consumers_monthly_release(self): + api = FakeGitHub() + consumer = "notaryproject/notation-go" + done = plan(repository=consumer, status="published") + done["notified"] = ["notaryproject/notation"] + api.issues = [{"number": 1, "body": body(done), "user": {"login": "test-actor"}, "state": "closed"}] + api.releases_by_repo[consumer] = [{"tag_name": "v1.3.1", "published_at": "2026-10-01T09:00:00Z", "draft": False, "prerelease": False}] + api.releases_by_repo[REPO] = [{"tag_name": "v1.3.1", "draft": False, "prerelease": False}] + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + result = release.prepare(api, consumer, "execute", "2026-10", ".", ".", False, True) + repeated = release.prepare(api, consumer, "execute", "2026-10", ".", ".", False, True) + self.assertEqual(result["tag"], "v1.3.2") + self.assertEqual(result["status"], "waiting") + self.assertRegex(result["cycle_id"], r"^upstream-[0-9a-f]{64}$") + self.assertEqual(repeated["cycle_id"], result["cycle_id"]) + self.assertEqual(len(api.issues), 2) + self.assertEqual(release.Cycle(api, consumer, "2026-10", "execute").state["status"], "published") + self.assertEqual(release.resume_cycle(api, consumer, "2026-11", "execute"), ("2026-10", result["cycle_id"])) + self.assertIsNone(release.decode_state(body(result), consumer, "2026-10", "execute")) + with self.assertRaises(ValueError): + release.validate_plan({**result, "cycle_id": "untrusted"}) + + def test_no_new_upstream_release_does_not_open_an_extra_consumer_cycle(self): + api = FakeGitHub() + consumer = "notaryproject/notation-go" + done = plan(repository=consumer, status="published") + done["notified"] = ["notaryproject/notation"] + api.issues = [{"number": 1, "body": body(done), "user": {"login": "test-actor"}, "state": "closed"}] + result = release.prepare(api, consumer, "execute", "2026-10", ".", ".", False, True) + self.assertEqual(result["action"], "complete") + self.assertFalse(api.writes) + self.assertTrue(release.includes_version("v1.3.2", "v1.3.1")) + self.assertFalse(release.includes_version("v1.3.0", "v1.3.1")) + + def test_cli_producer_snapshot_is_independent_of_both_libraries_monthly_status(self): + api = FakeGitHub() + versions = release.producer_versions(api, "notaryproject/notation", "execute") + self.assertEqual(set(versions), {"notation-core-go", "notation-go"}) + self.assertEqual(release.producer_lag(api, "notaryproject/notation", "main", "execute", versions), []) + api.releases_by_repo[REPO] = [{"tag_name": "v1.3.1", "draft": False, "prerelease": False}] + versions = release.producer_versions(api, "notaryproject/notation", "execute") + self.assertEqual(versions["notation-go"]["version"], "v1.3.0") + self.assertIn("notation-core-go", release.producer_lag(api, "notaryproject/notation", "main", "execute", versions)[0]) + self.assertNotEqual(release.upstream_cycle_id(versions), release.upstream_cycle_id({**versions, "notation-go": {"repository": "notaryproject/notation-go", "version": "v1.3.1"}})) + + def test_dispatch_fanout_duplicates_and_fork_scope(self): + api = FakeGitHub() + progress = list(release.dispatch_consumers(api, REPO, "execute", "v1.3.1")) + self.assertEqual(progress[-1], ["notaryproject/notation-go", "notaryproject/notation"]) + self.assertEqual([path for path, _, _ in api.writes], [ + "repos/notaryproject/notation-go/dispatches", "repos/notaryproject/notation/dispatches"]) + self.assertEqual(list(release.dispatch_consumers(api, REPO, "execute", "v1.3.1", progress[-1])), []) + + def test_notification_requires_completed_verified_cycle_and_records_progress(self): + api = FakeGitHub() + state = plan(status="published") + api.issues = [{"number": 1, "body": body(state), "user": {"login": "test-actor"}, "state": "closed"}] + with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): + notified = release.notify(api, state) + release.notify(api, state) + self.assertEqual(notified["notified"], ["notaryproject/notation-go", "notaryproject/notation"]) + self.assertEqual(sum(path.endswith("/dispatches") for path, _, _ in api.writes), 2) + api.issues[0]["body"] = body(plan(status="verifying")) + with self.assertRaisesRegex(ValueError, "successfully verified"): + release.notify(api, state) + + def test_partial_followup_notification_failure_is_resumed_without_duplicate_dispatches(self): + api = FakeGitHub() + state = plan(status="published", month="2026-09") + state["cycle_id"] = release.upstream_cycle_id({"producer": {"repository": REPO, "version": "v1.3.1"}}) + api.issues = [{"number": 1, "body": body(state), "user": {"login": "test-actor"}, "state": "closed"}] + request = api.request + def fail_second_dispatch(path, method="GET", payload=None): + if path == "repos/notaryproject/notation/dispatches": + raise release.APIError("HTTP 503") + return request(path, method, payload) + with patch.object(api, "request", side_effect=fail_second_dispatch), patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"), self.assertRaises(release.APIError): + release.notify(api, state) + stored = release.Cycle(api, REPO, "2026-09", "execute", state["cycle_id"]).state + self.assertEqual(stored["notified"], ["notaryproject/notation-go"]) + resumed = release.prepare(api, REPO, "execute", "2026-10", ".", ".", False, True) + self.assertEqual(resumed["cycle_id"], state["cycle_id"]) + self.assertEqual(resumed["month"], "2026-09") + with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): + release.notify(api, resumed) + paths = [path for path, _, _ in api.writes if path.endswith("/dispatches")] + self.assertEqual(paths, ["repos/notaryproject/notation-go/dispatches", "repos/notaryproject/notation/dispatches"]) + + def test_waiting_cycle_refreshes_targets_and_never_consumes_unverified_newer_version(self): + api = FakeGitHub() + consumer = "notaryproject/notation-go" + state = plan(repository=consumer, status="waiting") + state["producers"] = {"notation-core-go": {"repository": REPO, "version": "v1.3.1"}} + api.issues = [{"number": 1, "body": body(state), "user": {"login": "test-actor"}, "state": "open"}] + api.releases_by_repo[REPO] = [{"tag_name": "v1.3.2", "draft": False, "prerelease": False}] + with patch.object(release, "signing_environment", return_value=dict(os.environ)): + result = release.prepare(api, consumer, "execute", "2026-10", ".", ".", False, True) + self.assertEqual(result["producers"]["notation-core-go"]["version"], "v1.3.2") + self.assertIn("v1.3.2", result["reason"]) + api.manifests[(consumer, "main", ".")] = {"Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.3"}]} + self.assertTrue(release.producer_lag(api, consumer, "main", "execute", result["producers"])) + + def test_ordinary_stable_release_announces_but_automated_release_waits_for_verification(self): + api = FakeGitHub() + api.releases[0].update(tag_name="v1.3.1", body="") + with patch.dict(os.environ, {"GITHUB_REF": "refs/tags/v1.3.1"}): + result = release.announce(api, REPO, "execute", "v1.3.1") + self.assertEqual(len(result["notified"]), 2) + api.writes.clear() + api.releases[0]["body"] = body(plan(status="verifying")) + self.assertEqual(release.announce(api, REPO, "execute", "v1.3.1")["action"], "idle") + self.assertFalse(api.writes) + with self.assertRaises(ValueError): + release.announce(api, REPO, "execute", "v1.3.1") + + def test_baseline_drift_cannot_increment_a_stale_release(self): + api = FakeGitHub() + api.releases[0]["tag_name"] = "v1.4.0" + with self.assertRaisesRegex(ValueError, "advanced"): + release.baseline_plan(api, REPO, "2026-10", "execute", plan()) + + def test_existing_cli_tag_publisher_must_exclude_monthly_actor(self): + api = FakeGitHub() + old = " if: github.repository == 'notaryproject/notation'" + for content, guarded in [(old, False), (old + " && github.actor != vars.MONTHLY_PATCH_ACTOR", True)]: + workflow = {"encoding": "base64", "content": base64.b64encode(content.encode()).decode()} + with patch.object(api, "optional", return_value=workflow): + if guarded: + release.check_publisher_guard(api, "notaryproject/notation", "release-1.3") + else: + with self.assertRaisesRegex(ValueError, "automation-actor guard"): + release.check_publisher_guard(api, "notaryproject/notation", "release-1.3") + with patch.object(api, "optional", return_value=None): + release.check_publisher_guard(api, "notaryproject/notation", "release-1.3") + self.assertFalse(api.writes) + + def test_complete_is_idempotent_and_has_no_new_writes(self): + api = FakeGitHub() + api.issues = [{"body": body(plan(status="published")), "user": {"login": "test-actor"}, "state": "closed"}] + self.assertEqual(release.prepare(api, REPO, "execute", "2026-10", ".", ".", True)["action"], "complete") + self.assertFalse(api.writes) + + def test_tag_requires_annotated_signature_and_exact_commit(self): + api = FakeGitHub() + with patch.object(api, "request", side_effect=[ + {"object": {"type": "tag", "sha": PREVIOUS}}, + {"object": {"sha": COMMIT}, "verification": {"verified": True}}, + ]): + self.assertEqual(release.verified_tag(api, REPO, "v1.3.1", COMMIT), PREVIOUS) + for annotated in [ + {"object": {"sha": PREVIOUS}, "verification": {"verified": True}}, + {"object": {"sha": COMMIT}, "verification": {"verified": False}}, + ]: + with patch.object(api, "request", side_effect=[{"object": {"type": "tag", "sha": PREVIOUS}}, annotated]), self.assertRaises(ValueError): + release.verified_tag(api, REPO, "v1.3.1", COMMIT) + + def test_asset_recovery_rejects_changed_digest_size_visibility_and_extra_assets(self): + api = FakeGitHub() + candidate = {**plan(status="verifying"), "release_id": 1, "draft_verification": {"run_id": 1}, "draft_review_issue": 2, "assets": [ + {"name": name, "size": 10, "sha256": "a" * 64, "id": index + 10} + for index, name in enumerate(("notation-core-go_1.3.1_source.tar.gz", "notation-core-go_1.3.1_checksums.txt")) + ]} + public = {"id": 1, "tag_name": candidate["tag"], "draft": False, "prerelease": False, "body": body(candidate)} + assets = [{"id": item["id"], "name": item["name"], "size": 10, "digest": "sha256:" + "a" * 64} for item in candidate["assets"]] + asset = assets[0] + with patch.object(release, "check_draft_review"), patch.object(api, "request", return_value=public), patch.object(api, "pages", return_value=iter(assets)): + release.check_public_assets(api, candidate) + for changed in [[{**asset, "size": 11}, assets[1]], [{**asset, "digest": "sha256:changed"}, assets[1]], [asset, asset]]: + with patch.object(release, "check_draft_review"), patch.object(api, "request", return_value=public), patch.object(api, "pages", return_value=iter(changed)), self.assertRaises(ValueError): + release.check_public_assets(api, candidate) + with patch.object(release, "check_draft_review"), patch.object(api, "request", return_value={**public, "draft": True}), self.assertRaises(ValueError): + release.check_public_assets(api, candidate) + + def test_candidate_bundle_round_trip_without_remote_mutation(self): + self.candidate_bundle_round_trip(REPO) + + def test_late_core_release_builds_a_second_consumer_patch_with_signed_backport(self): + self.candidate_bundle_round_trip("notaryproject/notation-go", late_release=True) + + def candidate_bundle_round_trip(self, repository, late_release=False): + with tempfile.TemporaryDirectory(prefix="monthly-engine-test-") as temporary: + root = pathlib.Path(temporary) + origin, checkout, output = root / "origin.git", root / "checkout", root / "output" + subprocess.run(["git", "init", "--bare", "--quiet", str(origin)], check=True) + subprocess.run(["git", "clone", "--quiet", str(origin), str(checkout)], check=True) + for key, value in (("user.name", "Test"), ("user.email", "test@example.com"), ("commit.gpgsign", "false")): + release.git(["config", key, value], checkout) + before = "\nrequire github.com/notaryproject/notation-core-go v1.3.0\n" if late_release else "" + after = "\nrequire github.com/notaryproject/notation-core-go v1.3.1\n" if late_release else "" + (checkout / "go.mod").write_text("module example.com/test\n\ngo 1.26.0\n" + before) + release.git(["add", "go.mod"], checkout) + release.git(["commit", "-m", "Baseline"], checkout) + previous = release.git(["rev-parse", "HEAD"], checkout) + release.git(["branch", "-M", "main"], checkout) + release.git(["branch", "release-1.3"], checkout) + (checkout / "go.mod").write_text("module example.com/test\n\ngo 1.26.1\n" + after) + release.git(["add", "go.mod"], checkout) + release.git(["commit", "-m", "Dependency update"], checkout) + source_commit = release.git(["rev-parse", "HEAD"], checkout) + release.git(["push", "origin", "main", "release-1.3"], checkout) + api = FakeGitHub() + api.head = previous + api.closed_pulls = [{**pull(), "merged_at": "2025-12-01T00:00:00Z", "merge_commit_sha": source_commit}] + if late_release: + done = plan(repository=repository, status="published") + done["notified"] = ["notaryproject/notation"] + api.issues = [{"number": 1, "body": body(done), "user": {"login": "test-actor"}, "state": "closed"}] + api.releases_by_repo[repository] = [{"tag_name": "v1.3.1", "published_at": "2026-10-01T09:00:00Z", "draft": False, "prerelease": False}] + api.releases_by_repo[REPO] = [{"tag_name": "v1.3.1", "draft": False, "prerelease": False}] + api.manifests[(repository, "main", ".")] = {"Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.1"}]} + output.mkdir() + key = root / "test-key" + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) + api.public_key = key.with_suffix(".pub").read_text() + with patch.dict(os.environ, {"MONTHLY_PATCH_SIGNING_KEY": key.read_text()}): + candidate = release.prepare(api, repository, "execute", "2026-10", checkout, output, not late_release, late_release) + self.assertEqual(candidate["action"], "qualify") + self.assertEqual(candidate["minimum_go"], "1.26") + self.assertNotEqual(candidate["commit"], source_commit) + self.assertEqual(release.git([f"--git-dir={origin}", "rev-parse", "refs/heads/release-1.3"], checkout), previous) + self.assertIn(source_commit, release.git(["log", "-1", "--format=%B", candidate["commit"]], checkout)) + if late_release: + self.assertEqual(candidate["tag"], "v1.3.2") + self.assertTrue(candidate["cycle_id"].startswith("upstream-")) + self.assertIn("github.com/notaryproject/notation-core-go v1.3.1", release.git(["show", f"{candidate['commit']}:go.mod"], checkout)) + self.assertEqual(len(api.issues), 2) + release.import_candidate(checkout, candidate, output / "candidate.bundle") + self.assertEqual(release.git(["rev-parse", "HEAD"], checkout), candidate["commit"]) + with self.assertRaises(ValueError): + release.import_candidate(checkout, {**candidate, "commit": COMMIT}, output / "candidate.bundle") + if late_release: + candidate.update(status="published", notified=["notaryproject/notation"]) + release.Cycle(api, repository, "2026-10", "execute", candidate["cycle_id"]).save(candidate) + api.releases_by_repo[repository].append({"tag_name": "v1.3.2", "published_at": "2026-10-04T09:00:00Z", "draft": False, "prerelease": False}) + api.manifests[(repository, "v1.3.2", ".")] = api.manifests[(repository, "main", ".")] + repeated = release.prepare(api, repository, "execute", "2026-10", checkout, output, False, True) + self.assertEqual(repeated["action"], "complete") + self.assertEqual(len(api.issues), 2) + + def test_signing_key_is_private_registered_and_removed_with_temporary_directory(self): + api = FakeGitHub() + with tempfile.TemporaryDirectory(prefix="monthly-signing-test-") as temporary: + path = pathlib.Path(temporary) + key = path / "test-key" + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) + api.public_key = key.with_suffix(".pub").read_text() + secret = key.read_text() + location = path / "signing" + location.mkdir() + with patch.dict(os.environ, {"MONTHLY_PATCH_SIGNING_KEY": secret, "MONTHLY_PATCH_SIGNER_NAME": ""}): + environment = release.signing_environment(location, api) + self.assertEqual((location / "signing-key").stat().st_mode & 0o777, 0o600) + self.assertIn("ssh", environment.values()) + self.assertEqual(environment["GIT_CONFIG_VALUE_0"], "Monthly dependency release") + identity = subprocess.run(["git", "var", "GIT_COMMITTER_IDENT"], cwd=path, + env=environment, check=True, capture_output=True, text=True).stdout + self.assertTrue(identity.startswith("Monthly dependency release ")) + named = path / "named" + named.mkdir() + with patch.dict(os.environ, {"MONTHLY_PATCH_SIGNING_KEY": secret, + "MONTHLY_PATCH_SIGNER_NAME": "Rehearsal signer"}): + self.assertEqual(release.signing_environment(named, api)["GIT_CONFIG_VALUE_0"], "Rehearsal signer") + mismatch = path / "unregistered" + mismatch.mkdir() + api.public_key = "ssh-ed25519 different" + with patch.dict(os.environ, {"MONTHLY_PATCH_SIGNING_KEY": secret}), self.assertRaisesRegex(ValueError, "not registered"): + release.signing_environment(mismatch, api) + self.assertFalse(path.exists()) + + def test_tag_push_uses_atomic_exact_leases_without_follow_tags(self): + api = FakeGitHub() + candidate = plan() + api.issues = [{"number": 1, "body": body(candidate), "user": {"login": "test-actor"}}] + with patch.object(release, "signing_environment", return_value=dict(os.environ)), patch.object(release, "verified_tag", return_value=PREVIOUS), patch.object(release, "git") as command: + command.side_effect = [COMMIT, "", COMMIT, ""] + result = release.tag_candidate(api, candidate, ".") + args = command.call_args.args[0] + self.assertIn("--atomic", args) + self.assertIn("push.followTags=false", args) + self.assertIn(f"--force-with-lease=refs/heads/release-1.3:{PREVIOUS}", args) + self.assertIn("--force-with-lease=refs/tags/v1.3.1:", args) + self.assertEqual(result["status"], "tagged") + + def test_tag_push_rechecks_stable_baseline_after_qualification(self): + api = FakeGitHub() + api.issues = [{"body": body(plan()), "user": {"login": "test-actor"}}] + api.releases[0]["tag_name"] = "v1.4.0" + with patch.object(release, "git") as command, self.assertRaisesRegex(ValueError, "advanced"): + release.tag_candidate(api, plan(), ".") + command.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_monthly_release_checks.py b/.github/scripts/test_monthly_release_checks.py new file mode 100644 index 000000000..67b38c9a8 --- /dev/null +++ b/.github/scripts/test_monthly_release_checks.py @@ -0,0 +1,223 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import io +import json +import pathlib +import os +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import monthly_release_checks as checks +from qualify_monthly_candidate import license_config + + +def scanner_messages(mode="binary", findings=True): + messages = [ + {"config": {"protocol_version": "v1.0.0", "scanner_name": "govulncheck", + "scanner_version": "v1.8.0", "scan_level": "symbol", + "scan_mode": mode, "db": "https://vuln.go.dev"}}, + {"SBOM": {"modules": [{"path": "example.com/test", "version": "v1.0.0"}]}}, + ] + if findings: + messages += [ + {"osv": {"id": "GO-2026-1234"}}, + {"finding": {"osv": "GO-2026-1234", "trace": [ + {"module": "example.com/test", "package": "example.com/test", "function": "Verify"}]}}, + ] + return messages + + +def stream(messages): + return "\n".join(json.dumps(item) for item in messages) + + +def metadata(tag="v1.3.1", commit="a" * 40, flags=""): + return f"""notation: go1.27.1 + path github.com/notaryproject/notation/cmd/notation + mod github.com/notaryproject/notation {tag} + build -ldflags="-w {flags} -X github.com/notaryproject/notation/internal/version.Version={tag.removeprefix('v')} -X github.com/notaryproject/notation/internal/version.GitCommit={commit}" + build CGO_ENABLED=0 + build GOOS=darwin + build GOARCH=arm64 +""" + + +class MonthlyReleaseCheckTests(unittest.TestCase): + def test_source_archive_matches_exactly_despite_windows_git_crlf_conversion(self): + with tempfile.TemporaryDirectory() as temporary: + source = pathlib.Path(temporary) + subprocess.run(["git", "init", "--quiet", str(source)], check=True) + (source / "go.mod").write_bytes(b"module example.com/library\n\ngo 1.26\n") + subprocess.run(["git", "add", "go.mod"], cwd=source, check=True) + subprocess.run(["git", "-c", "commit.gpgsign=false", "-c", "user.name=Test", + "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "Fixture"], + cwd=source, check=True) + commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source, text=True).strip() + command = ["git", "archive", "--format=tar", "--prefix=notation-core-go-1.3.1/", commit] + expected = subprocess.check_output(["git", "-c", "core.autocrlf=false", *command[1:]], cwd=source) + subprocess.run(["git", "config", "core.autocrlf", "true"], cwd=source, check=True) + self.assertNotEqual(subprocess.check_output(command, cwd=source), expected) + self.assertEqual(checks.library_source_archive(source, "notation-core-go", "v1.3.1", commit), expected) + self.assertEqual(subprocess.check_output(["git", "config", "core.autocrlf"], + cwd=source, text=True).strip(), "true") + with self.assertRaisesRegex(ValueError, "Source archive failed"): + checks.library_source_archive(source, "notation-core-go", "v1.3.1", "missing-ref") + + def test_asset_names_preserve_six_standard_platform_archives_and_checksum(self): + names = checks.asset_names("notation", "v1.3.1") + self.assertEqual(len(names), 7) + self.assertIn("notation_1.3.1_windows_amd64.zip", names) + self.assertIn("notation_1.3.1_linux_armv7.tar.gz", names) + self.assertEqual(checks.asset_names("notation-go", "v1.3.1"), [ + "notation-go_1.3.1_source.tar.gz", "notation-go_1.3.1_checksums.txt"]) + + def test_scanner_parses_json_findings_even_when_json_exit_is_zero(self): + self.assertEqual(checks.scan_messages(stream(scanner_messages()), "binary")[:2], ({"GO-2026-1234"}, set())) + messages = scanner_messages() + messages.insert(3, messages[2]) + self.assertEqual(checks.scan_messages(stream(messages), "binary")[0], {"GO-2026-1234"}) + messages[-1]["finding"]["trace"][0].pop("function") + self.assertEqual(checks.scan_messages(stream(messages), "binary")[:2], (set(), {"GO-2026-1234"})) + + def test_scanner_rejects_missing_duplicate_config_sbom_and_unknown_messages(self): + cases = [ + [], scanner_messages()[1:], scanner_messages() + [scanner_messages()[0]], + scanner_messages() + [{"unknown": {}}], scanner_messages()[:1], + scanner_messages() + [{"osv": {"id": "GO-2026-1234", "modified": "changed"}}], + scanner_messages() + [{"finding": {"osv": "GO-2026-9999", "trace": []}}], + ] + for messages in cases: + with self.subTest(messages=messages), self.assertRaises(ValueError): + checks.scan_messages(stream(messages), "binary") + with self.assertRaises(ValueError): + checks.scan_messages(stream(scanner_messages(mode="source")), "binary") + with self.assertRaises(ValueError): + checks.scan_messages('{"config":{},"config":{}}', "binary") + + def test_binary_gate_retains_evidence_and_blocks_without_trial_exceptions(self): + scanned = subprocess.CompletedProcess([], 0, stream(scanner_messages()), "") + rendered = subprocess.CompletedProcess([], 3, "Vulnerability", "") + with tempfile.TemporaryDirectory() as temporary, patch.object(checks.subprocess, "run", side_effect=[scanned, rendered]), self.assertRaisesRegex(ValueError, "reachable vulnerabilities"): + checks.scan(["govulncheck"], temporary, "binary") + self.fail("A zero JSON exit must not hide vulnerabilities") + scanned = subprocess.CompletedProcess([], 0, stream(scanner_messages(findings=False)), "") + with tempfile.TemporaryDirectory() as temporary, patch.object(checks.subprocess, "run", side_effect=[scanned, rendered]), self.assertRaisesRegex(ValueError, "disagree"): + checks.scan(["govulncheck"], temporary, "binary") + + def test_metadata_requires_exact_version_commit_symbols_and_root_module(self): + checks.verify_metadata(metadata(), "v1.3.1", "a" * 40, "darwin_arm64") + for content in [ + metadata(commit="b" * 40), metadata(flags="-s"), + metadata().replace("mod github.com/notaryproject/notation v1.3.1", "mod github.com/notaryproject/notation (devel)"), + metadata().replace("GOOS=darwin", "GOOS=linux"), + metadata().replace("CGO_ENABLED=0", "CGO_ENABLED=1"), + metadata(flags="-X github.com/notaryproject/notation/internal/version.Version=1.3.1"), + ]: + with self.subTest(content=content), self.assertRaises(ValueError): + checks.verify_metadata(content, "v1.3.1", "a" * 40, "darwin_arm64") + + def test_checksums_reject_duplicate_escape_invalid_hash_and_missing_platforms(self): + with tempfile.TemporaryDirectory() as temporary: + path = pathlib.Path(temporary) / "checksums.txt" + digest = "a" * 64 + for content in [ + "", f"{digest} ../archive.tar.gz\n", f"{digest} a\\b\n", + "bad archive.tar.gz\n", f"{digest} archive.tar.gz\n{digest} archive.tar.gz\n", + ]: + path.write_text(content) + with self.subTest(content=content), self.assertRaises(ValueError): + checks.checksum_entries(path) + path = pathlib.Path(temporary) / "notation_1.3.1_checksums.txt" + path.write_text(f"{digest} notation_1.3.1_darwin_arm64.tar.gz\n") + with self.assertRaisesRegex(ValueError, "exact release"): + checks.verify_checksums(temporary, "notation", "v1.3.1", "darwin_arm64") + + def test_archive_extraction_does_not_extract_paths_or_follow_symlinks(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + archive = root / "test.tar.gz" + with tarfile.open(archive, "w:gz") as target: + entry = tarfile.TarInfo("notation") + entry.size = 4 + target.addfile(entry, io.BytesIO(b"test")) + escape = tarfile.TarInfo("../escaped") + escape.size = 5 + target.addfile(escape, io.BytesIO(b"other")) + output = checks.extract_binary(archive, root / "binary") + self.assertEqual(output.read_bytes(), b"test") + self.assertFalse((root.parent / "escaped").exists()) + with tarfile.open(archive, "w:gz") as target: + entry = tarfile.TarInfo("notation") + entry.type = tarfile.SYMTYPE + entry.linkname = "../escaped" + target.addfile(entry) + with self.assertRaises(ValueError): + checks.extract_binary(archive, root / "binary") + + def test_all_module_licensing_preserves_repository_policy(self): + with tempfile.TemporaryDirectory() as temporary: + source = pathlib.Path(temporary) + (source / ".github").mkdir() + (source / ".github/licenserc.yml").write_text( + "header:\n comment: on-failure\n\ndependency:\n files:\n - ../go.mod\n licenses:\n - Apache-2.0\n") + config = license_config(source, (".", "test/e2e", "test/e2e/plugin")) + self.assertIn("comment: on-failure", config) + self.assertIn("licenses:\n - Apache-2.0", config) + self.assertIn(str(source / "test/e2e/plugin/go.mod"), config) + + def test_packaged_e2e_passes_downloaded_binary_without_make_or_rebuild(self): + with patch.object(checks, "checked") as command: + checks.packaged_e2e(pathlib.Path("/source"), pathlib.Path("/download/notation"), pathlib.Path("/evidence.log")) + self.assertEqual(command.call_args.args[0], ["bash", "./run.sh", "zot", "/download/notation"]) + self.assertEqual(command.call_args.kwargs["cwd"], pathlib.Path("/source/test/e2e")) + + def test_no_repository_context_can_waive_reachable_findings(self): + for repository in ("", "notaryproject/notation", "untrusted/notation"): + with patch.dict(os.environ, {"GITHUB_REPOSITORY": repository, "MONTHLY_PATCH_ADVISORY_DISPOSITIONS": "[]"}), \ + self.assertRaises(ValueError): + checks.accepted_findings({"GO-2026-1234"}, {}) + with patch.dict(os.environ, {"MONTHLY_PATCH_ADVISORY_DISPOSITIONS": ""}): + self.assertEqual(checks.accepted_findings({"GO-2026-1234"}, {}), set()) + + def test_canonical_releases_cannot_reuse_fork_advisory_dispositions(self): + with patch.dict(os.environ, { + "GITHUB_REPOSITORY": "notaryproject/notation", + "MONTHLY_PATCH_ADVISORY_DISPOSITIONS": "[]", + }), self.assertRaisesRegex(ValueError, "Canonical"): + checks.accepted_findings(set(), {}) + + def test_workflow_gates_remote_tagging_on_binary_checks_and_public_completion(self): + workflow = pathlib.Path(__file__).parent.parent / "workflows/monthly-patch-worker.yml" + content = workflow.read_text() + self.assertLess(content.index("build_monthly_assets.py"), content.index("monthly_release.py tag")) + self.assertIn("needs.verify.result == 'success'", content) + self.assertIn("fail-fast: false", content) + self.assertIn(" contents: read", content) + self.assertNotIn("pull_request_target:", content) + self.assertIn("inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN", content) + self.assertIn("inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY", content) + self.assertNotIn(" schedule:", content) + self.assertNotIn("dependency-patch-upstream", content) + self.assertNotIn("--reconcile", content) + self.assertIn("MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'", content) + self.assertIn("monthly_release.py notify-controller", content) + self.assertIn("--cycle-id", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_monthly_release_pr.py b/.github/scripts/test_monthly_release_pr.py new file mode 100644 index 000000000..0bed6300c --- /dev/null +++ b/.github/scripts/test_monthly_release_pr.py @@ -0,0 +1,245 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import os +import pathlib +import tempfile +import unittest +from unittest.mock import patch + +import monthly_release as release +import monthly_release_pr as prs +import monthly_release_vote as voting +from test_monthly_release import checks, plan +import test_monthly_release_vote as fixtures + +BASE, HEAD, TREE, MERGE = (character * 40 for character in "abcd") +REPO = "notaryproject/notation-core-go" + + +def expected(): + return {"repository": REPO, "actor": "test-actor", "base_ref": "release-1.3", "base_sha": BASE, + "head_ref": "monthly-patch/2026-10/plan/dependencies", "head_sha": HEAD, "head_tree": TREE, + "tag": "v1.3.1", "number": 7} + + +class API: + def __init__(self): + self.pull = { + "number": 7, "state": "open", "draft": False, "user": {"login": "test-actor"}, + "base": {"ref": "release-1.3", "sha": BASE, "repo": {"full_name": REPO}}, + "head": {"ref": expected()["head_ref"], "sha": HEAD, "repo": {"full_name": REPO}}, + } + self.tip = BASE + self.commit = { + "sha": MERGE, "parents": [{"sha": BASE}, {"sha": HEAD}], + "commit": {"tree": {"sha": TREE}, "verification": {"verified": True}}, + } + self.checks_result = checks(HEAD) + self.writes = [] + + def request(self, path, method="GET", payload=None): + if method != "GET": + self.writes.append((path, method, payload)) + if path.endswith("/merge"): + self.pull.update(state="closed", merged_at="2026-10-13T00:00:00Z", merge_commit_sha=MERGE) + self.tip = MERGE + return {"merged": True, "sha": MERGE} + raise AssertionError(path) + if path == f"repos/{REPO}": + return {"default_branch": "main"} + if "/branches/" in path: + return {"commit": {"sha": self.tip}} + if "/pulls/" in path: + return self.pull + if "/commits/" in path: + return self.commit + raise AssertionError(path) + + def checks(self, repository, number): + return self.checks_result + + +class ReleasePRTests(unittest.TestCase): + def setUp(self): + self.api = API() + + def test_backport_waits_for_normal_reviews_and_ci(self): + self.api.checks_result["reviewDecision"] = "REVIEW_REQUIRED" + self.assertIsNone(prs.merge_pull(self.api, expected(), self.api.pull)) + self.assertEqual(self.api.writes, []) + + def test_normal_merge_pins_head_and_preserves_merge_tip(self): + result = prs.merge_pull(self.api, expected(), self.api.pull) + self.assertEqual(result, MERGE) + self.assertEqual(self.api.writes[0][2], {"sha": HEAD, "merge_method": "merge"}) + self.assertEqual(self.api.tip, MERGE) + + def test_concurrent_base_change_blocks_merge(self): + self.api.tip = HEAD + with self.assertRaisesRegex(ValueError, "advanced"): + prs.merge_pull(self.api, expected(), self.api.pull) + self.assertEqual(self.api.writes, []) + + def test_changed_head_or_actor_cannot_merge(self): + for field, value in (("user", {"login": "attacker"}), ("head", {**self.api.pull["head"], "sha": BASE})): + with self.subTest(field=field): + changed = {**self.api.pull, field: value} + with self.assertRaises(ValueError): + prs.merge_pull(self.api, expected(), changed) + self.assertEqual(self.api.writes, []) + + def test_squash_wrong_tree_or_unsigned_merge_cannot_qualify(self): + prs.merge_pull(self.api, expected(), self.api.pull) + for commit in ( + {**self.api.commit, "parents": [{"sha": BASE}]}, + {**self.api.commit, "commit": {"tree": {"sha": BASE}, "verification": {"verified": True}}}, + {**self.api.commit, "commit": {"tree": {"sha": TREE}, "verification": {"verified": False}}}, + ): + with patch.object(self.api, "request", side_effect=lambda path, method="GET", payload=None: commit if "/commits/" in path else {"commit": {"sha": MERGE}}), self.assertRaises(ValueError): + prs.merge_pull(self.api, expected(), self.api.pull) + + def test_closed_unmerged_pr_is_not_skipped_or_published(self): + self.api.pull["state"] = "closed" + with self.assertRaisesRegex(ValueError, "closed without"): + prs.merge_pull(self.api, expected(), self.api.pull) + + def test_required_version_update_has_no_other_source_changes(self): + before = 'package signer\nconst signingAgent = "notation-go/1.3.0"\nfunc Sign() {}\n' + self.assertEqual(prs.version_metadata("notaryproject/notation-go", before, "v1.3.1"), + before.replace("notation-go/1.3.0", "notation-go/1.3.1")) + cli = 'var (\n\tVersion = "v1.3.0"\n\tBuildMetadata = "unreleased"\n\tGitCommit = ""\n)\n' + self.assertEqual(prs.version_metadata("notaryproject/notation", cli, "v1.3.1"), + cli.replace("v1.3.0", "v1.3.1").replace('BuildMetadata = "unreleased"', 'BuildMetadata = ""')) + with self.assertRaises(ValueError): + prs.version_metadata(REPO, before, "v1.3.1") + + def test_ambiguous_version_declaration_is_rejected(self): + for text in ("", 'Version = "v1"\nVersion = "v2"\nBuildMetadata = ""\n'): + with self.assertRaises(ValueError): + prs.version_metadata("notaryproject/notation", text, "v1.3.1") + + def test_core_release_uses_exact_dependency_head_not_merge_commit(self): + candidate = plan(REPO) + candidate.update(commit=HEAD, previous_head=BASE, controller_plan_id="e" * 64, + release_backport=expected()) + cycle = unittest.mock.Mock() + with patch.dict(os.environ, {"MONTHLY_PATCH_ACTOR": "test-actor"}): + result = prs.advance(self.api, candidate, pathlib.Path("."), {}, cycle) + self.assertEqual(result["action"], "qualify") + self.assertEqual(result["commit"], HEAD) + self.assertEqual(result["previous_head"], MERGE) + prs.validate(self.api, result) + + def test_canonical_tag_push_keeps_already_merged_release_tip(self): + candidate = plan(REPO) + candidate.update(commit=HEAD, previous_head=MERGE, release_backport=expected(), controller_issue=1) + commands = [] + def git(command, directory, environment=None): + commands.append(command) + if command[:2] == ["rev-parse", "HEAD"] or command[:1] == ["rev-parse"]: + return HEAD + return "" + cycle = unittest.mock.Mock() + cycle.return_value.state = copy.deepcopy(candidate) + with patch.object(release, "policy"), patch.object(release, "coordinator_authorization"), \ + patch.object(release, "Cycle", cycle), patch.object(release, "baseline_plan"), \ + patch.object(release, "signing_environment", return_value={}), \ + patch.object(prs, "validate"), patch.object(release, "assert_tag", return_value=TREE), \ + patch.object(release, "git", side_effect=git), patch.object(self.api, "optional", return_value=None, create=True): + release.tag_candidate(self.api, candidate, pathlib.Path(".")) + push = next(command for command in commands if "push" in command) + self.assertIn(f"{MERGE}:refs/heads/release-1.3", push) + self.assertNotIn(f"{HEAD}:refs/heads/release-1.3", push) + self.assertIn(f"--force-with-lease=refs/heads/release-1.3:{MERGE}", push) + self.assertIn("--force-with-lease=refs/tags/v1.3.1:", push) + + def vote_fixture(self): + fixture = fixtures.VoteTests() + fixture.setUp() + reader, responses = fixture.api() + repo = fixture.expected["repository"] + before, dependency_head = "1" * 40, "2" * 40 + backport = { + "repository": repo, "number": 7, "actor": "robot", "base_ref": "release-1.3", + "base_sha": before, "head_ref": "monthly/dependencies", "head_sha": dependency_head, + "head_tree": fixtures.CANDIDATE_TREE, "merge_commit_sha": fixtures.BASE, "tag": "v1.3.1", + } + responses[f"repos/{repo}/pulls/7"] = { + "number": 7, "user": {"login": "robot"}, "state": "closed", "merged_at": "2026-10-13T00:00:00Z", + "base": {"ref": "release-1.3", "sha": before, "repo": {"full_name": repo}}, + "head": {"ref": "monthly/dependencies", "sha": dependency_head, "repo": {"full_name": repo}}, + "merge_commit_sha": fixtures.BASE, + } + responses[f"repos/{repo}/commits/{fixtures.BASE}"].update( + parents=[{"sha": before}, {"sha": dependency_head}]) + responses[f"repos/{repo}/commits/{fixtures.BASE}"]["commit"]["verification"] = {"verified": True} + responses[f"repos/{repo}/branches/release-1.3"] = {"commit": {"sha": fixtures.MERGE}} + candidate = {**fixture.plan(), "mode": "execute", "status": "voting", "release_backport": backport} + return fixture, reader, responses, candidate + + def test_existing_separate_vote_completes_without_recreating_or_rewinding_prs(self): + fixture, reader, responses, candidate = self.vote_fixture() + cycle = unittest.mock.Mock() + result = prs.advance(reader, candidate, pathlib.Path("."), {}, cycle) + self.assertEqual(result["action"], "qualify") + self.assertEqual(result["commit"], fixtures.HEAD) + self.assertEqual(result["previous_head"], fixtures.MERGE) + self.assertEqual(result["release_vote"]["receipt"], fixture.expected["receipt"]) + prs.validate(reader, result) + + def test_separate_vote_waits_for_current_head_majority(self): + fixture, reader, responses, candidate = self.vote_fixture() + fixture.reviews.pop() + result = prs.advance(reader, candidate, pathlib.Path("."), {}, unittest.mock.Mock()) + self.assertEqual(result["action"], "waiting") + self.assertEqual(result["status"], "voting") + self.assertIn("majority", result["reason"]) + + def test_vote_stage_rejects_source_changes_even_after_majority_approval(self): + fixture, reader, responses, candidate = self.vote_fixture() + compare = responses[f"repos/{fixture.expected['repository']}/compare/{fixtures.BASE}...{fixtures.HEAD}"] + compare["files"].append({"filename": "feature.go", "status": "added"}) + with self.assertRaises(voting.VoteError): + prs.advance(reader, candidate, pathlib.Path("."), {}, unittest.mock.Mock()) + + def test_reassessment_rebinds_only_an_unchanged_roster(self): + for changed in (False, True): + with self.subTest(changed=changed): + candidate = plan(REPO, "execute", status="voting") + candidate.update(controller_plan_id="1" * 64, maintainer_roster_sha=BASE, + release_vote={"maintainer_roster_sha": BASE}) + authorization = {"plan_id": "2" * 64, "snapshot": {"automation_sha": HEAD}} + cycle = unittest.mock.Mock(state=candidate) + def roster(api, repository, path, sha): + return "new maintainers" if changed and sha == HEAD else "same maintainers" + with patch.object(release, "policy"), patch.object(release, "coordinator_authorization", return_value=authorization), \ + patch.object(release, "resume_cycle", return_value=("2026-10", "monthly")), \ + patch.object(release, "Cycle", return_value=cycle), patch.object(prs, "content", side_effect=roster), \ + patch.object(release, "signing_environment", return_value={}), \ + patch.object(prs, "advance", return_value={**candidate, "action": "waiting"}) as advance: + if changed: + with self.assertRaisesRegex(ValueError, "new release vote"): + release.prepare(self.api, REPO, "execute", "2026-10", pathlib.Path("."), ".") + cycle.save.assert_not_called() + advance.assert_not_called() + else: + release.prepare(self.api, REPO, "execute", "2026-10", pathlib.Path("."), ".") + self.assertEqual(candidate["maintainer_roster_sha"], HEAD) + self.assertEqual(candidate["release_vote"]["maintainer_roster_sha"], HEAD) + cycle.save.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_monthly_release_vote.py b/.github/scripts/test_monthly_release_vote.py new file mode 100644 index 000000000..e94b0e5d4 --- /dev/null +++ b/.github/scripts/test_monthly_release_vote.py @@ -0,0 +1,390 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import copy +import unittest + +import monthly_release_vote as voting + + +BASE, HEAD, TREE, CANDIDATE_TREE, MERGE, ROSTER = [char * 40 for char in "abcdef"] +MAINTAINERS = """# Org-Level Maintainers (in alphabetical order) +Org Person (@org) +# Repo-Level Maintainers (in alphabetical order) +# Note: This is for the repo +A Person (@alice) +B Person (@bob) +C Person (@carol) +# Emeritus Repo-Level Maintainers (in alphabetical order) +Old Person (@old) +""" + + +class VoteTests(unittest.TestCase): + def setUp(self): + self.expected = { + "repository": "notaryproject/notation-go", "number": 42, "actor": "robot", + "base_ref": "release-1.3", "base_sha": BASE, + "head_ref": "monthly/2026-10/vote", "head_sha": HEAD, "head_tree": TREE, + "candidate_sha": BASE, "candidate_tree": CANDIDATE_TREE, "tag": "v1.3.1", + "signer_login": "robot", "merge_committer_login": "web-flow", + "maintainer_roster_sha": ROSTER, + "metadata_before": 'package signer\nconst signingAgent = "notation-go/1.3.0"\n', + "metadata_after": 'package signer\nconst signingAgent = "notation-go/1.3.1"\n', + } + self.pr = { + "number": 42, "state": "open", "draft": False, "user": {"login": "robot"}, + "base": {"ref": self.expected["base_ref"], "sha": BASE, + "repo": {"full_name": self.expected["repository"]}}, + "head": {"ref": self.expected["head_ref"], "sha": HEAD, + "repo": {"full_name": self.expected["repository"]}}, + "body": f"https://github.com/notaryproject/notation-go/blob/{BASE}/MAINTAINERS\n" + f"https://github.com/notaryproject/notation-go/compare/v1.3.0...v1.3.1", + "merged_at": None, "merge_commit_sha": None, + } + self.reviews = [self.review("alice", 1), self.review("bob", 2)] + self.diff = { + "files": [{"filename": "signer/signer.go", "status": "modified"}], + "before": self.expected["metadata_before"], "after": self.expected["metadata_after"], + } + + def review(self, login, ident, state="APPROVED", head=HEAD): + return {"id": ident, "user": {"login": login}, "state": state, "commit_id": head, + "submitted_at": f"2026-10-13T05:00:{ident:02d}Z"} + + def assess(self): + return voting.assess_vote(self.expected, self.pr, self.reviews, MAINTAINERS, self.diff) + + def merge(self): + self.pr.update(state="closed", merged_at="2026-10-13T06:00:00Z", merge_commit_sha=MERGE) + return {"sha": MERGE, "parents": [{"sha": BASE}, {"sha": HEAD}], + "commit": {"tree": {"sha": TREE}, + "verification": {"verified": True, "reason": "valid"}}, + "committer": {"login": "web-flow"}} + + def test_strict_majority_and_roster(self): + result = self.assess() + self.assertEqual(result["status"], "ready") + self.assertEqual(result["required"], 2) + self.assertEqual(result["maintainers"], ["alice", "bob", "carol"]) + + def test_insufficient_votes_wait_with_evidence(self): + self.reviews = self.reviews[:1] + result = self.assess() + self.assertEqual(result["status"], "waiting") + self.assertEqual(result["approvals"], ["alice"]) + self.assertIn("majority", result["reason"]) + + def test_org_and_emeritus_votes_do_not_count(self): + self.reviews = [self.review("org", 1), self.review("old", 2)] + self.assertEqual(self.assess()["approvals"], []) + + def test_case_insensitive_unique_votes(self): + self.reviews.append(self.review("ALICE", 3)) + self.assertEqual(self.assess()["approvals"], ["alice", "bob"]) + + def test_latest_review_supersedes_even_if_results_unordered(self): + self.reviews.insert(0, self.review("alice", 3, "CHANGES_REQUESTED")) + result = self.assess() + self.assertEqual(result["status"], "waiting") + self.assertEqual(result["blocking"], ["alice"]) + + def test_dismissal_revokes_vote(self): + self.reviews.append(self.review("alice", 3, "DISMISSED")) + self.assertEqual(self.assess()["approvals"], ["bob"]) + + def test_comment_does_not_replace_vote(self): + self.reviews.append(self.review("alice", 3, "COMMENTED")) + self.assertEqual(self.assess()["status"], "ready") + + def test_stale_approval_never_counts(self): + self.reviews[0]["commit_id"] = BASE + self.assertEqual(self.assess()["status"], "waiting") + + def test_missing_and_malformed_roster_fail(self): + for text in ("", "# Org-Level Maintainers\nA (@alice)", + "# Repo-Level Maintainers\nbad", MAINTAINERS.replace("(@bob)", "(@alice)")): + with self.subTest(text=text), self.assertRaises(voting.VoteError): + voting.parse_repo_maintainers(text) + + def test_roster_section_not_confused_with_emeritus(self): + text = MAINTAINERS.replace("# Repo-Level Maintainers", "# Emeritus Repo-Level Maintainers") + with self.assertRaises(voting.VoteError): + voting.parse_repo_maintainers(text) + + def test_review_corruption_rejected(self): + for field, value in (("id", 1), ("submitted_at", "bad"), + ("submitted_at", "2026-01-01T00:00:00"), ("state", "PENDING")): + with self.subTest(field=field, value=value): + original = copy.deepcopy(self.reviews) + self.reviews[1][field] = value + with self.assertRaises(voting.VoteError): + self.assess() + self.reviews = original + + def test_changed_pr_identity_rejected(self): + for side, field, value in ( + ("head", "sha", BASE), ("base", "sha", HEAD), + ("head", "ref", "attacker"), ("base", "ref", "main"), + ("head", "repo", {"full_name": "fork/notation-go"}), + ("base", "repo", {"full_name": "fork/notation-go"}), + ): + with self.subTest(side=side, field=field): + pr = copy.deepcopy(self.pr) + self.pr[side][field] = value + with self.assertRaises(voting.VoteError): + self.assess() + self.pr = pr + for field, value in (("number", 43), ("user", {"login": "attacker"}), ("body", "")): + with self.subTest(field=field): + pr = copy.deepcopy(self.pr) + self.pr[field] = value + with self.assertRaises(voting.VoteError): + self.assess() + self.pr = pr + + def test_draft_waits_and_closed_unmerged_rejects(self): + self.pr["draft"] = True + self.assertEqual(self.assess()["status"], "waiting") + self.pr.update(draft=False, state="closed") + with self.assertRaises(voting.VoteError): + self.assess() + + def test_source_change_rejected_even_when_pinned(self): + self.expected["metadata_after"] += "func arbitrary() {}\n" + self.diff["after"] = self.expected["metadata_after"] + with self.assertRaises(voting.VoteError): + self.assess() + + def test_other_file_or_renamed_metadata_rejected(self): + for files in ([{"filename": "go.mod", "status": "modified"}], + [{"filename": "signer/signer.go", "status": "renamed"}], + self.diff["files"] + [{"filename": "go.sum", "status": "modified"}]): + with self.subTest(files=files), self.assertRaises(voting.VoteError): + voting.assess_vote(self.expected, self.pr, self.reviews, MAINTAINERS, + {**self.diff, "files": files}) + + def test_wrong_metadata_version_rejected(self): + self.expected["metadata_after"] = self.diff["after"] = self.diff["before"] + with self.assertRaises(voting.VoteError): + self.assess() + + def test_cli_version_and_build_metadata_only(self): + self.expected["repository"] = "notaryproject/notation" + self.expected["metadata_before"] = 'package version\nvar (\n\tVersion = "v1.3.0"\n\tBuildMetadata = "unreleased"\n\tGitCommit = ""\n)\n' + self.expected["metadata_after"] = 'package version\nvar (\n\tVersion = "v1.3.1"\n\tBuildMetadata = ""\n\tGitCommit = ""\n)\n' + for side in ("head", "base"): + self.pr[side]["repo"]["full_name"] = self.expected["repository"] + self.pr["body"] = self.pr["body"].replace("notation-go/", "notation/") + self.diff = {"files": [{"filename": "internal/version/version.go", "status": "modified"}], + "before": self.expected["metadata_before"], + "after": self.expected["metadata_after"]} + self.assertEqual(self.assess()["status"], "ready") + self.diff["after"] = self.expected["metadata_after"] = self.diff["after"].replace( + 'GitCommit = ""', 'GitCommit = "unexpected"') + with self.assertRaises(voting.VoteError): + self.assess() + + def test_core_requires_declared_candidate_scope_without_metadata(self): + self.expected.update(repository="notaryproject/notation-core-go", + vote_scope="dependency-candidate", candidate_sha=HEAD, + candidate_tree=TREE) + for side in ("head", "base"): + self.pr[side]["repo"]["full_name"] = self.expected["repository"] + self.pr["body"] = self.pr["body"].replace("notation-go/", "notation-core-go/") + self.diff = {"files": [], "before": None, "after": None} + self.assertEqual(self.assess()["status"], "ready") + del self.expected["vote_scope"] + with self.assertRaises(voting.VoteError): + self.assess() + + def test_normal_verified_merge_receipt(self): + commit = self.merge() + receipt = voting.qualify_release_commit(self.expected, self.pr, commit) + self.assertEqual(receipt["sha"], HEAD) + self.assertEqual(receipt["merge_commit_sha"], MERGE) + self.assertEqual(receipt["tree"], TREE) + + def test_merge_provenance_violations(self): + commit = self.merge() + mutations = [ + {"parents": [{"sha": BASE}]}, {"parents": [{"sha": HEAD}, {"sha": BASE}]}, + {"parents": [{"sha": BASE}, {"sha": HEAD}, {"sha": ROSTER}]}, + {"sha": BASE}, {"committer": {"login": "attacker"}}, + {"commit": {"tree": {"sha": CANDIDATE_TREE}, + "verification": {"verified": True, "reason": "valid"}}}, + {"commit": {"tree": {"sha": TREE}, + "verification": {"verified": False, "reason": "unsigned"}}}, + ] + for mutation in mutations: + with self.subTest(mutation=mutation), self.assertRaises(voting.VoteError): + voting.qualify_release_commit(self.expected, self.pr, {**commit, **mutation}) + + def test_unauthorized_expected_merge_committer_rejected(self): + commit = self.merge() + self.expected["merge_committer_login"] = "attacker" + commit["committer"]["login"] = "attacker" + with self.assertRaisesRegex(voting.VoteError, "configured actor"): + voting.qualify_release_commit(self.expected, self.pr, commit) + + def test_invalid_pinned_plan_identity(self): + for key, value in (("head_sha", "unknown"), ("tag", "v1.4.0"), + ("base_ref", "main"), ("candidate_sha", HEAD), + ("repository", "fork/notation-go"), ("number", 0), + ("head_ref", "")): + with self.subTest(key=key): + expected = copy.deepcopy(self.expected) + self.expected[key] = value + with self.assertRaises(voting.VoteError): + self.assess() + self.expected = expected + + def api(self): + merge = self.merge() + self.expected["receipt"] = voting.qualify_release_commit(self.expected, self.pr, merge) + repo = self.expected["repository"] + def content(text): + return {"type": "file", "encoding": "base64", + "content": base64.b64encode(text.encode()).decode()} + signed = {"verified": True, "reason": "valid"} + responses = { + f"repos/{repo}/pulls/42": self.pr, + f"repos/{repo}/contents/MAINTAINERS?ref={ROSTER}": content(MAINTAINERS), + f"repos/{repo}/contents/signer/signer.go?ref={BASE}": + content(self.expected["metadata_before"]), + f"repos/{repo}/contents/signer/signer.go?ref={HEAD}": + content(self.expected["metadata_after"]), + f"repos/{repo}/compare/{BASE}...{HEAD}": + {"status": "ahead", "ahead_by": 1, "behind_by": 0, "files": self.diff["files"]}, + f"repos/{repo}/commits/{BASE}": + {"sha": BASE, "commit": {"tree": {"sha": CANDIDATE_TREE}}}, + f"repos/{repo}/commits/{HEAD}": + {"sha": HEAD, "parents": [{"sha": BASE}], "committer": {"login": "robot"}, + "commit": {"tree": {"sha": TREE}, "verification": signed, + "message": "bump: release v1.3.1"}}, + f"repos/{repo}/commits/{MERGE}": merge, + f"repos/{repo}/git/ref/heads/release-1.3": {"object": {"sha": MERGE}}, + } + class API: + def request(api, path): + return responses[path] + def pages(api, path): + self.assertEqual(path, f"repos/{repo}/pulls/42/reviews") + return iter(self.reviews) + return API(), responses + + def plan(self): + return {"repository": self.expected["repository"], "tag": self.expected["tag"], + "commit": HEAD, "previous_head": MERGE, "baseline": "v1.3.0", + "maintainer_roster_sha": ROSTER, + "release_vote": self.expected} + + def test_revalidate_all_evidence_before_publish(self): + api, _ = self.api() + self.assertEqual(voting.validate_vote(api, self.plan()), self.expected["receipt"]) + + def test_revalidate_changed_tip_or_votes_or_receipt(self): + api, responses = self.api() + path = "repos/notaryproject/notation-go/git/ref/heads/release-1.3" + responses[path]["object"]["sha"] = HEAD + with self.assertRaisesRegex(voting.VoteError, "branch moved"): + voting.validate_vote(api, self.plan()) + responses[path]["object"]["sha"] = MERGE + self.reviews.append(self.review("alice", 3, "DISMISSED")) + with self.assertRaisesRegex(voting.VoteError, "majority"): + voting.validate_vote(api, self.plan()) + self.reviews.pop() + self.expected["receipt"]["tree"] = CANDIDATE_TREE + with self.assertRaisesRegex(voting.VoteError, "receipt changed"): + voting.validate_vote(api, self.plan()) + + def test_revalidate_unsigned_head_and_changed_candidate(self): + api, responses = self.api() + head = responses[f"repos/notaryproject/notation-go/commits/{HEAD}"] + head["commit"]["verification"]["verified"] = False + with self.assertRaisesRegex(voting.VoteError, "signature"): + voting.validate_vote(api, self.plan()) + head["commit"]["verification"]["verified"] = True + responses[f"repos/notaryproject/notation-go/commits/{BASE}"]["commit"]["tree"]["sha"] = TREE + with self.assertRaisesRegex(voting.VoteError, "candidate tree"): + voting.validate_vote(api, self.plan()) + + def test_revalidate_uses_only_assessed_current_roster(self): + api, _ = self.api() + plan = self.plan() + plan["maintainer_roster_sha"] = HEAD + with self.assertRaisesRegex(voting.VoteError, "trusted maintainer roster"): + voting.validate_vote(api, plan) + + def test_revalidate_rejects_missing_assessed_roster(self): + api, _ = self.api() + plan = self.plan() + del plan["maintainer_roster_sha"] + with self.assertRaisesRegex(voting.VoteError, "trusted maintainer roster"): + voting.validate_vote(api, plan) + + def test_revalidate_rejects_extra_metadata_commits(self): + api, responses = self.api() + responses[f"repos/notaryproject/notation-go/compare/{BASE}...{HEAD}"]["ahead_by"] = 2 + with self.assertRaisesRegex(voting.VoteError, "candidate scope"): + voting.validate_vote(api, self.plan()) + + def test_revalidate_rejects_changed_metadata_parent(self): + api, responses = self.api() + responses[f"repos/notaryproject/notation-go/commits/{HEAD}"]["parents"] = [{"sha": ROSTER}] + with self.assertRaisesRegex(voting.VoteError, "parent changed"): + voting.validate_vote(api, self.plan()) + + def test_revalidate_rejects_nonconventional_voting_commit(self): + api, responses = self.api() + responses[f"repos/notaryproject/notation-go/commits/{HEAD}"]["commit"]["message"] = "release" + with self.assertRaisesRegex(voting.VoteError, "conventional"): + voting.validate_vote(api, self.plan()) + + def test_revalidate_rejects_tagging_merge_instead_of_voted_head(self): + api, _ = self.api() + plan = self.plan() + plan["commit"] = MERGE + with self.assertRaisesRegex(voting.VoteError, "voted head"): + voting.validate_vote(api, plan) + + def test_app_actor_and_nonroster_bot_review(self): + self.expected["actor"] = "release-app[bot]" + self.pr["user"]["login"] = "release-app[bot]" + self.reviews.append(self.review("dependabot[bot]", 3)) + self.assertEqual(self.assess()["approvals"], ["alice", "bob"]) + + def test_vote_can_include_unmerged_qualified_dependency_candidate(self): + candidate_sha = "1" * 40 + self.expected["candidate_sha"] = candidate_sha + api, responses = self.api() + root = "repos/notaryproject/notation-go" + candidate = copy.deepcopy(responses[f"{root}/commits/{BASE}"]) + candidate.update(sha=candidate_sha, parents=[{"sha": BASE}], + committer={"login": "robot"}) + candidate["commit"]["verification"] = {"verified": True, "reason": "valid"} + responses[f"{root}/commits/{candidate_sha}"] = candidate + responses[f"{root}/compare/{candidate_sha}...{HEAD}"] = responses.pop( + f"{root}/compare/{BASE}...{HEAD}") + responses[f"{root}/contents/signer/signer.go?ref={candidate_sha}"] = responses.pop( + f"{root}/contents/signer/signer.go?ref={BASE}") + responses[f"{root}/commits/{HEAD}"]["parents"] = [{"sha": candidate_sha}] + self.assertEqual(voting.validate_vote(api, self.plan())["sha"], HEAD) + candidate["parents"] = [{"sha": ROSTER}] + with self.assertRaisesRegex(voting.VoteError, "candidate parent"): + voting.validate_vote(api, self.plan()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_notation_release_controller.py b/.github/scripts/test_notation_release_controller.py new file mode 100644 index 000000000..da9566d50 --- /dev/null +++ b/.github/scripts/test_notation_release_controller.py @@ -0,0 +1,874 @@ +# Copyright The Notary Project Authors. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import copy +import base64 +import datetime +import json +import os +import pathlib +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import monthly_release as release +import notation_release_controller as coordinator +from test_upstream_authorization import UpstreamAPI as ControllerAPI, inventory as canonical_inventory +from test_upstream_authorization import ApprovedController +from test_monthly_release import FakeGitHub, plan as worker_plan, body, pull +from test_monthly_release_checks import scanner_messages, stream + +CORE, GO, CLI = coordinator.REPOSITORIES +MAIN, BRANCH, AUTOMATION, PR_HEAD = ("a" * 40, "b" * 40, "a" * 40, "c" * 40) +MONTH = "2026-10" + +def inventory(changes=()): + return canonical_inventory(changes) + + + +def assessment(evidence, decisions=("skip", "skip", "skip")): + return { + "schema": 1, "month": evidence["month"], "inventory_sha256": coordinator.digest(evidence), + "decisions": {repository: {"decision": decision, "reason": "Evidence-based decision", + "evidence": [f"{repository}:dependencies"]} + for repository, decision in zip(coordinator.REPOSITORIES, decisions)}, + } + + + + +class ReleaseAssessmentTests(unittest.TestCase): + def setUp(self): + self.environment = patch.dict(os.environ, {"MONTHLY_PATCH_ACTOR": "test-actor"}) + self.environment.start() + self.addCleanup(self.environment.stop) + + def test_core_patch_includes_both_consumers(self): + evidence = inventory((CORE,)) + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + self.assertEqual(set(plan["decisions"]), set(coordinator.REPOSITORIES)) + self.assertNotIn("body", plan["snapshots"][CORE]["pulls"][0]) + + def test_go_only_and_cli_only_do_not_invent_core_patch(self): + for changes, decisions in (((GO,), ("skip", "release", "release")), ((CLI,), ("skip", "skip", "release"))): + with self.subTest(changes=changes): + evidence = inventory(changes) + plan = coordinator.validate_assessment(evidence, assessment(evidence, decisions)) + self.assertEqual(plan["decisions"][CORE]["decision"], "skip") + + def test_no_changes_yields_three_skips(self): + evidence = inventory() + plan = coordinator.validate_assessment(evidence, assessment(evidence)) + self.assertEqual({item["decision"] for item in plan["decisions"].values()}, {"skip"}) + + def test_downstream_cannot_skip_planned_or_deferred_producer(self): + for decisions in (("release", "skip", "release"), ("release", "release", "skip"), ("defer", "skip", "skip")): + evidence = inventory((CORE,)) + with self.subTest(decisions=decisions), self.assertRaisesRegex(ValueError, "downstream"): + coordinator.validate_assessment(evidence, assessment(evidence, decisions)) + + def test_missing_setup_is_deferred_not_skipped_or_released(self): + evidence = inventory() + evidence["repositories"][CORE]["blockers"] = ["Missing reviewed branch"] + for decisions in (("skip", "skip", "skip"), ("release", "release", "release")): + with self.subTest(decisions=decisions), self.assertRaises(ValueError): + coordinator.validate_assessment(evidence, assessment(evidence, decisions)) + coordinator.validate_assessment(evidence, assessment(evidence, ("defer", "skip", "skip"))) + + def test_known_cve_requires_fix_or_deferral(self): + evidence = inventory() + evidence["repositories"][CORE]["scans"]["."]["reachable"] = ["GO-2026-1234"] + with self.assertRaises(ValueError): + coordinator.validate_assessment(evidence, assessment(evidence)) + with self.assertRaisesRegex(ValueError, "empty patch"): + coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + coordinator.validate_assessment(evidence, assessment(evidence, ("defer", "defer", "defer"))) + + def test_informational_findings_do_not_force_empty_patch(self): + evidence = inventory() + evidence["repositories"][CORE]["scans"]["."]["informational"] = ["GO-2026-1234"] + coordinator.validate_assessment(evidence, assessment(evidence)) + + def test_extra_repo_fields_and_fabricated_evidence_are_rejected(self): + evidence = inventory() + valid = assessment(evidence) + invalids = [ + {**valid, "command": "publish"}, + {**valid, "schema": True}, + {**valid, "inventory_sha256": "0" * 64}, + {**valid, "month": "2026-11"}, + {**valid, "decisions": {**valid["decisions"], "untrusted/notation": valid["decisions"][CLI]}}, + ] + for invalid in invalids: + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + coordinator.validate_assessment(evidence, invalid) + invalid = copy.deepcopy(valid) + invalid["decisions"][CLI]["evidence"] = ["notaryproject/notation:pr:999"] + with self.assertRaisesRegex(ValueError, "fabricated"): + coordinator.validate_assessment(evidence, invalid) + invalid["decisions"][CLI]["evidence"] = [f"{CORE}:baseline"] + with self.assertRaisesRegex(ValueError, "own repository"): + coordinator.validate_assessment(evidence, invalid) + + def test_incomplete_scan_or_branch_namespace_is_rejected(self): + for field, value in (("scans", {}), ("branch", "main"), ("main_head", "bad"), ("source_repository", "untrusted/notation")): + evidence = inventory() + evidence["repositories"][CORE][field] = value + with self.subTest(field=field), self.assertRaises(ValueError): + coordinator.validate_assessment(evidence, assessment(evidence)) + + def test_duplicate_json_fields_are_rejected(self): + with self.assertRaisesRegex(ValueError, "Duplicate JSON"): + coordinator.load_json('{"schema":1,"schema":1}') + + def test_source_scan_preserves_reachable_findings_as_evidence(self): + scanned = subprocess.CompletedProcess([], 0, stream(scanner_messages("source")), "") + with tempfile.TemporaryDirectory() as temporary, patch.object(release, "git", return_value=BRANCH), patch.object( + coordinator.subprocess, "run", return_value=scanned + ): + result = coordinator.scan_source(CORE, BRANCH, (".",), pathlib.Path(temporary)) + self.assertEqual(result["."]["reachable"], ["GO-2026-1234"]) + + def test_source_scan_errors_do_not_become_clean_evidence(self): + for scanned in (subprocess.CompletedProcess([], 1, "", "network failed"), + subprocess.CompletedProcess([], 0, '{"config":{}}', "")): + with tempfile.TemporaryDirectory() as temporary, patch.object(release, "git", return_value=BRANCH), patch.object( + coordinator.subprocess, "run", return_value=scanned + ), self.assertRaises(ValueError): + coordinator.scan_source(CORE, BRANCH, (".",), pathlib.Path(temporary)) + + def test_comparison_must_be_complete(self): + api = ControllerAPI() + with patch.object(api, "request", return_value={"status": "ahead", "ahead_by": 2, "commits": [{"sha": MAIN}]}), self.assertRaisesRegex(ValueError, "Incomplete"): + coordinator.compare_commits(api, CORE, BRANCH, MAIN) + + def test_workflow_registration_does_not_prove_installation_on_main(self): + api = ControllerAPI() + self.assertFalse(coordinator.installed_worker(api, CORE, AUTOMATION)) + required = { + coordinator.WORKER_PATH: f"jobs:\n release:\n uses: {coordinator.HOST}/{coordinator.SHARED_PATH}@{AUTOMATION}\n with:\n worker_sha: {AUTOMATION}\n", + coordinator.SHARED_PATH: " workflow_call:\nMONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'\n", + ".github/scripts/notation_release_controller.py": "def worker_authorization(", + ".github/scripts/monthly_release.py": "coordinator_authorization(", + ".github/scripts/monthly_release_pr.py": "def advance(\ndef validate(", + ".github/scripts/monthly_release_vote.py": "def validate_vote(", + ".github/scripts/qualify_monthly_candidate.py": "def qualify(", + ".github/scripts/build_monthly_assets.py": "def ", + ".github/scripts/test_monthly_public_release.py": "def ", + ".github/scripts/release_issue_topology.py": "def scope_body(\ndef ensure(", + ".github/scripts/release_bugfix_proposals.py": "def collect(\ndef validate_proposals(", + } + def file(path): + self.assertTrue(path.endswith("?ref=" + AUTOMATION)) + name = path.split("/contents/", 1)[1].split("?")[0] + if name != coordinator.WORKER_PATH: + self.assertTrue(path.startswith("repos/" + coordinator.HOST + "/contents/")) + return {"encoding": "base64", "content": base64.b64encode(required[name].encode()).decode()} + with patch.object(api, "optional", side_effect=file): + self.assertTrue(coordinator.installed_worker(api, CORE, AUTOMATION)) + required[".github/scripts/monthly_release.py"] = "legacy independent publisher" + self.assertFalse(coordinator.installed_worker(api, CORE, AUTOMATION)) + + +class FailureNotificationTests(unittest.TestCase): + def setUp(self): + self.environment = patch.dict(os.environ, {"MONTHLY_PATCH_ACTOR": "test-actor"}) + self.environment.start() + self.addCleanup(self.environment.stop) + self.api = ControllerAPI() + self.controller = ApprovedController(self.api, "test-actor") + evidence = inventory((CORE,)) + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + self.issue, self.state = self.controller.approve(plan, 22) + self.run = {"id": 900, "run_attempt": 1} + + def notify(self, **kwargs): + return self.controller.report_failure( + self.issue, self.state, kwargs.get("repository", CORE), + kwargs.get("run", self.run), kwargs.get("reason", "Qualification failed"), + kwargs.get("stages", ["qualify"]), + ) + + def alerts(self): + return [comment for comment in self.api.comments.values() + if "