From 217075907c37f8e48e1bfd289df926892926ecdb Mon Sep 17 00:00:00 2001 From: Yi Zha <107919912+yizha1@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:58:09 -0700 Subject: [PATCH 1/5] ci: add disabled coordinated monthly patch worker Install the canonical shared worker, authorization and release-vote helpers, regression tests, and Go setup runbook. Preserve existing release voting, CI, and dependency versions. Activation requires separate maintainer approval and companion CLI setup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc64f14-49a5-4a09-8b61-fd3ee5500435 Signed-off-by: Yi Zha <107919912+yizha1@users.noreply.github.com> --- .gitattributes | 1 + .github/MONTHLY_PATCH.md | 83 ++ .github/scripts/build_monthly_assets.py | 66 + .github/scripts/check_notation_fork_setup.py | 135 ++ .github/scripts/monthly_cli_smoke.py | 115 ++ .github/scripts/monthly_release.py | 1150 ++++++++++++++++ .github/scripts/monthly_release_checks.py | 281 ++++ .github/scripts/monthly_release_pr.py | 256 ++++ .github/scripts/monthly_release_vote.py | 302 +++++ .github/scripts/notation_fork_propagation.py | 200 +++ .../scripts/notation_release_controller.py | 1184 +++++++++++++++++ .github/scripts/qualify_monthly_candidate.py | 82 ++ .../scripts/test_monthly_public_release.py | 86 ++ .github/scripts/test_monthly_release.py | 707 ++++++++++ .../scripts/test_monthly_release_checks.py | 234 ++++ .github/scripts/test_monthly_release_pr.py | 245 ++++ .github/scripts/test_monthly_release_vote.py | 390 ++++++ .github/scripts/test_notation_fork_release.py | 490 +++++++ .../test_notation_release_controller.py | 826 ++++++++++++ .../scripts/test_upstream_authorization.py | 380 ++++++ .github/workflows/monthly-patch-release.yml | 513 +++++++ .../workflows/notation-release-validation.yml | 95 ++ 22 files changed, 7821 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/MONTHLY_PATCH.md create mode 100644 .github/scripts/build_monthly_assets.py create mode 100644 .github/scripts/check_notation_fork_setup.py create mode 100644 .github/scripts/monthly_cli_smoke.py create mode 100644 .github/scripts/monthly_release.py create mode 100644 .github/scripts/monthly_release_checks.py create mode 100644 .github/scripts/monthly_release_pr.py create mode 100644 .github/scripts/monthly_release_vote.py create mode 100644 .github/scripts/notation_fork_propagation.py create mode 100644 .github/scripts/notation_release_controller.py create mode 100644 .github/scripts/qualify_monthly_candidate.py create mode 100644 .github/scripts/test_monthly_public_release.py create mode 100644 .github/scripts/test_monthly_release.py create mode 100644 .github/scripts/test_monthly_release_checks.py create mode 100644 .github/scripts/test_monthly_release_pr.py create mode 100644 .github/scripts/test_monthly_release_vote.py create mode 100644 .github/scripts/test_notation_fork_release.py create mode 100644 .github/scripts/test_notation_release_controller.py create mode 100644 .github/scripts/test_upstream_authorization.py create mode 100644 .github/workflows/monthly-patch-release.yml create mode 100644 .github/workflows/notation-release-validation.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..965a9c32 --- /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 00000000..fa9a5da7 --- /dev/null +++ b/.github/MONTHLY_PATCH.md @@ -0,0 +1,83 @@ +# Monthly patch setup + +The monthly patch controller and full proposal live in the companion +[notation CLI repository](https://github.com/notaryproject/notation). This +repository installs the shared worker and validation workflow from that change. +It does not run a separate assessment schedule or require a Copilot token. The +shared controller helper supports authorization checks, not a second scheduled +controller. + +## Merge-time state + +Maintainers must leave the Actions variable `MONTHLY_PATCH_ENABLED` unset or +`false` when merging the setup. Merging this change is not authorization to +activate automation, change repository settings, or publish a release. The +companion CLI controller and this repository's worker must +both be installed before activation. + +## Repository settings + +| Setting | Required setup | +| --- | --- | +| Repository features | Enable GitHub Actions and Issues. | +| Dependabot | Keep Go modules and GitHub Actions updates enabled on `main`. The existing `.github/dependabot.yml` configures both weekly and does not override the default target branch. | +| Release branch | Maintain the current latest stable `release-X.Y` branch with required CI checks and reviews. Determine the branch from the current stable release, not from the version on `main`. | +| Merge methods | Enable **Allow merge commits**. Preserve the required normal merge method for release voting PRs. | +| Stable baseline | Ensure the current stable baseline tag is signed and its signature/provenance can be verified by the worker. An unverifiable baseline blocks execution. | +| Actions secret `MONTHLY_PATCH_TOKEN` | Provide an authenticated-user token whose `GET /user` identity matches `MONTHLY_PATCH_ACTOR` across Core, Go, and CLI. Scope it to the three repositories with contents, pull requests, issues, and Actions write permissions, plus workflow write permission if workflow updates are in scope. GitHub App installation tokens are unsupported. Do not grant branch-rule bypass. | +| Actions secret `MONTHLY_PATCH_SIGNING_KEY` | Store the private release-signing key as a secret, never as a repository file, variable, issue, or PR attachment. | +| Actions variable `MONTHLY_PATCH_ACTOR` | Set the same automation actor login used by the companion Core and CLI changes. | +| Signer identity variables | Set `MONTHLY_PATCH_SIGNER_LOGIN` and `MONTHLY_PATCH_SIGNER_EMAIL`. Optionally set `MONTHLY_PATCH_SIGNER_NAME`. Ensure the identity matches the signing key. | +| Signing-key registration | Register only the public signing key on the designated signer's GitHub account. | +| Actions variable `MONTHLY_PATCH_ENABLED` | Leave unset or `false` until maintainers explicitly authorize activation after companion setup and validation. | + +Maintainers must verify the configured token can perform the required operations +without bypassing required checks or reviews. + +## Coordination and release approvals + +Go has its own third-party dependency updates. A Go cycle waits for Core only +when Core publishes a release in the same coordinated cycle. In that case, the +exact planned canonical `github.com/notaryproject/notation-core-go` version must +pass the checked producer PR before release. Fork `replace` directives are not +an acceptable substitute. + +The CLI controller owns cycle assessment, policy authorization, and coordination. +Maintainers must approve that policy and activation explicitly. Worker execution +must honor the authorized cycle and preserve the release approval points in +[Release Checklist](../RELEASE_CHECKLIST.md), including a majority of repo-level +maintainer approvals on the release voting PR, the required merge-commit method, +and a signed release tag. Setup approval does not replace release voting. + +The majority-maintainer vote and merge-commit requirement apply to the separate +release voting PR that updates `signingAgent`, not to the preceding dependency +bump PR. Dependency PRs still require the configured CI and reviews. The release +voting PR description must link the change logs and repo-level maintainer list. +Automation must not waive these governance gates. + +The signed release tag targets the single signed commit recorded by the voting +PR. The release branch retains the normal merge commit, whose tree must match +the voted commit. The worker must recheck the exact candidate, current-head +maintainer majority, and merge provenance before publication. +The controller pins the active repo-level roster from the default branch at +assessment time. Roster changes require reassessment; a historical stable tag +does not supply the voting roster. +The worker validates the metadata delta against the exact qualified dependency +candidate. A release voting PR can include those recorded backports plus the +required metadata update without granting permission for other source changes. + +## Activation + +1. Maintainers review and merge the companion CLI controller and the + Go worker, with automation still disabled. +2. Administrators configure the settings above and confirm the required CI and + review rules apply to the selected stable release branch. +3. Maintainers review the shared payload's focused test and workflow-lint results + and approve the controller's authorization policy and activation. +4. Administrators set `MONTHLY_PATCH_ENABLED` to `true` only after those approvals. + They can return it to `false` to disable future worker execution. + +The worker defaults to a no-write `dry-run`. Canonical execution requires a +controller-authorized cycle, plan digest, and attempt from the CLI, along with +the enabled setting and configured actor. A manual dispatch alone does not +authorize execution. diff --git a/.github/scripts/build_monthly_assets.py b/.github/scripts/build_monthly_assets.py new file mode 100644 index 00000000..819a1e7b --- /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/check_notation_fork_setup.py b/.github/scripts/check_notation_fork_setup.py new file mode 100644 index 00000000..2ba8b955 --- /dev/null +++ b/.github/scripts/check_notation_fork_setup.py @@ -0,0 +1,135 @@ +# 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. + +"""Check all fork release prerequisites using metadata, never secret values.""" + +import argparse +import base64 +import datetime +import json +import pathlib + +import monthly_release as release +import notation_release_controller as controller + + +def named_metadata(api, path, field): + items = [] + for page in range(1, 101): + result = api.request(f"{path}?per_page=100&page={page}") + batch = result[field] + items.extend(batch) + if len(items) == result["total_count"]: + return {item["name"]: item for item in items} + if len(batch) < 100: + raise ValueError("Incomplete setup metadata response") + raise ValueError("Setup metadata exceeds the bounded pagination limit") + + +def check(api, month, dependabot_confirmed=False): + result = {"schema": 1, "month": month, "repositories": {}, "ready": True} + actors = {} + for repository in controller.REPOSITORIES: + metadata = controller.fork_metadata(api, repository) + baseline = release.baseline_plan(api, repository, month, "rehearse") + automation = api.request(f"repos/{repository}/branches/main")["commit"]["sha"] + blockers = [] + if not metadata["has_issues"]: + blockers.append("Enable Issues") + if not dependabot_confirmed: + blockers.append("Enable Dependabot version updates in fork Settings > Advanced Security and confirm all three forks") + if not controller.installed_worker(api, repository, automation): + blockers.append("Install the current coordinated worker and fork propagation helpers on main") + for branch in (baseline["main"], baseline["branch"]): + ref = api.optional(f"repos/{repository}/branches/{branch}") + if ref is None: + blockers.append(f"Create reviewed branch {branch}") + continue + commit = ref["commit"]["sha"] + ci = api.optional(f"repos/{repository}/contents/.github/workflows/notation-fork-ci.yml?ref={commit}") + if ci is None: + blockers.append(f"Install fork dependency CI on {branch}") + else: + if ci.get("encoding") != "base64": + raise ValueError("Cannot inspect fork dependency CI") + content = base64.b64decode(ci["content"]).decode("utf-8") + if "qualify_monthly_candidate.py" not in content or " pull_request:" not in content: + blockers.append(f"Use qualification-backed PR CI on {branch}") + if repository == controller.HOST: + try: + release.check_publisher_guard(api, repository, commit) + except ValueError as error: + blockers.append(f"{branch}: {error}") + for directory in release.modules(repository): + manifest = api.manifest(repository, commit, directory) + for producer in release.PROJECTS[repository.split("/")[1]]: + module = f"github.com/notaryproject/{producer}" + if directory != "." and not any(item["Path"] == module for item in manifest.get("Require") or []): + continue + try: + release.requirement(manifest, module, f"yizha1/{producer}", "rehearse") + except ValueError as error: + blockers.append(f"{branch}:{directory}: {error}") + dependabot = api.optional(f"repos/{repository}/contents/.github/dependabot.yml?ref={automation}") + if dependabot is None: + blockers.append("Install the fork Dependabot configuration on main") + else: + if dependabot.get("encoding") != "base64": + raise ValueError("Cannot inspect Dependabot setup") + if f"target-branch: {baseline['main']}" not in base64.b64decode(dependabot["content"]).decode("utf-8"): + blockers.append("Configure Dependabot to target monthly-patch-test-main") + secrets = named_metadata(api, f"repos/{repository}/actions/secrets", "secrets") + for name in ("MONTHLY_PATCH_TOKEN", "MONTHLY_PATCH_SIGNING_KEY"): + if name not in secrets: + blockers.append(f"Install Actions secret {name}") + if repository == controller.HOST and "COPILOT_GITHUB_TOKEN" not in secrets: + blockers.append("Install Actions secret COPILOT_GITHUB_TOKEN") + variables = named_metadata(api, f"repos/{repository}/actions/variables", "variables") + actors[repository] = variables.get("MONTHLY_PATCH_ACTOR", {}).get("value", "") + for name in ("MONTHLY_PATCH_ACTOR", "MONTHLY_PATCH_SIGNER_LOGIN", "MONTHLY_PATCH_SIGNER_EMAIL"): + if not variables.get(name, {}).get("value"): + blockers.append(f"Set Actions variable {name}") + signer = variables.get("MONTHLY_PATCH_SIGNER_LOGIN", {}).get("value", "") + if actors[repository] and signer and actors[repository] != signer: + blockers.append("Use the actor's signing identity for fork producer PRs") + enabled = ["MONTHLY_PATCH_REHEARSAL_ENABLED"] + if repository == controller.HOST: + enabled.append("NOTATION_RELEASE_COORDINATOR_ENABLED") + for name in enabled: + if variables.get(name, {}).get("value") != "true": + blockers.append(f"Set {name}=true after setup review") + result["repositories"][repository] = {"automation_sha": automation, "baseline": baseline["baseline"], "blockers": blockers} + host_actor = actors[controller.HOST] + for repository, state in result["repositories"].items(): + if host_actor and actors[repository] and actors[repository] != host_actor: + state["blockers"].append("Use the same MONTHLY_PATCH_ACTOR as the CLI coordinator") + result["ready"] = all(not state["blockers"] for state in result["repositories"].values()) + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--month", default=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m")) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--confirm-fork-dependabot-enabled", action="store_true", + help="Owner confirmation that Dependabot version updates are enabled in all three fork settings") + args = parser.parse_args() + result = check(release.GitHub(), args.month, args.confirm_fork_dependabot_enabled) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + for repository, state in result["repositories"].items(): + print(f"{repository}: {'READY' if not state['blockers'] else 'BLOCKED'}") + for blocker in state["blockers"]: + print(" " + blocker) + raise SystemExit(0 if result["ready"] else 1) diff --git a/.github/scripts/monthly_cli_smoke.py b/.github/scripts/monthly_cli_smoke.py new file mode 100644 index 00000000..90d873f5 --- /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 00000000..5403e436 --- /dev/null +++ b/.github/scripts/monthly_release.py @@ -0,0 +1,1150 @@ +# 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 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"[A-Za-z0-9_.-]+/(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 not in {"execute", "rehearse"} 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", "rehearse"}: + 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") + else: + if not metadata.get("fork") or repository.split("/")[0] == "notaryproject": + raise ValueError("Rehearsal writes require a fork, never the canonical repositories") + if environment.get("MONTHLY_PATCH_REHEARSAL_ENABLED") != "true": + raise ValueError("Fork rehearsal execution is not enabled") + 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): + 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 + if mode == "rehearse": + expected = f"github.com/{producer_repository}" + if len(replacements) != 1 or replacements[0]["New"]["Path"] != expected: + raise ValueError(f"Fork rehearsal must explicitly track {expected}, not the upstream module") + return replacements[0]["New"]["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", "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 mode == "rehearse": + expected_branch = "monthly-patch-test-" + expected_branch + expected_main = "monthly-patch-test-main" + tag_match = re.fullmatch( + rf"v{major}\.{minor}\.([0-9]+)-monthly-test\.{month.replace('-', '')}", + state.get("tag", ""), + ) + if not tag_match or int(tag_match[1]) <= patch: + raise ValueError("Rehearsal tag escapes the isolated cycle namespace") + elif 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", "verifying", "published"} and not SHA.fullmatch(state.get("commit", "")): + raise ValueError("Cycle has no valid candidate commit") + if state["status"] in {"ready", "tagged", "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"{owner}/{producer}" if mode == "rehearse" else f"notaryproject/{producer}" + releases = list(api.pages(f"repos/{target}/releases")) + candidates = [] + for item in releases: + if item["draft"] or (mode != "rehearse" and item["prerelease"]): + continue + version = semver(item["tag_name"]) + if mode == "rehearse": + match = re.fullmatch(r"(v\d+\.\d+\.\d+)-monthly-test\.\d{6}", item["tag_name"]) + version = semver(match[1]) if match else None + 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: + if mode == "rehearse": + # Without a verified fork release, consumers keep their current dependencies. + continue + 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_public_assets(api, plan): + release = api.request(f"repos/{plan['repository']}/releases/tags/{plan['tag']}") + if release["draft"] or release["prerelease"] != (plan["mode"] == "rehearse"): + 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 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 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 = f"notaryproject/{repository.split('/')[1]}" if mode in {"rehearse", "dry-run"} else repository + baseline = latest_release(list(api.pages(f"repos/{source}/releases"))) + major, minor, patch = semver(baseline["tag_name"]) + branch = f"monthly-patch-test-release-{major}.{minor}" if mode == "rehearse" else f"release-{major}.{minor}" + tag = f"v{major}.{minor}.{patch + 1}" + if mode == "rehearse": + existing = [ + tuple(map(int, match.groups())) + for item in api.pages(f"repos/{repository}/tags") + if (match := re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?", item["name"])) + and tuple(map(int, match.groups()))[:2] == (major, minor) + ] + patch = max([patch, *(item[2] for item in existing)]) + tag = f"v{major}.{minor}.{patch + 1}-monthly-test.{month.replace('-', '')}" + 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": "monthly-patch-test-main" if mode == "rehearse" else "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 fork_propagation_pull, 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 + fork_propagation_pull(api, repository, pull, authorization)) + 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 = "rehearse" if mode == "rehearse" else "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", "verifying", + }: + cycle.state["controller_plan_id"] = authorization["plan_id"] + if mode == "execute" and cycle.state["status"] != "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["branch"] if mode == "rehearse" else 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", "verifying"}: + assert_tag(api, cycle.state) + if cycle.state["status"] == "verifying": + check_public_assets(api, cycle.state) + return {**cycle.state, "action": "verify" if cycle.state["status"] == "verifying" else "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 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 in {"execute", "rehearse"}: + 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: + from notation_release_controller import fork_propagation_pull + return fork_propagation_pull(api, repository, pull, authorization) + 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) + 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}") + 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, 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): + 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 plan["mode"] == "execute" and plan.get("controller_issue"): + from monthly_release_pr import validate + validate(api, plan) + if authorization and authorization["snapshot"].get("verification_recovery") is not None: + raise ValueError("Verification-only recovery cannot build or republish release assets") + assert_tag(api, plan) + 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") + notes = pathlib.Path(os.environ["RUNNER_TEMP"]) / "monthly-release-notes.txt" + notes.write_text( + f"# {plan['repository'].split('/')[1]} {plan['tag']}\n\n" + + ("Fork rehearsal only; not an official release.\n\n" if plan["mode"] == "rehearse" else "") + + 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" + + "\nPost-publication verification is pending; the cycle completes only after it passes.\n", + encoding="utf-8", + ) + releases = list(api.pages(f"repos/{plan['repository']}/releases")) + existing = next((item for item in releases if item["tag_name"] == plan["tag"]), None) + if existing: + # Recovery must use the originally published bytes, not a new rebuild. + manifest = re.findall(r"Asset verification manifest:\n```json\n(.*?)\n```", existing.get("body") or "", re.DOTALL) + if len(manifest) != 1: + raise ValueError("Existing release has no unambiguous asset manifest") + assets = json.loads(manifest[0]) + else: + command = [ + "gh", "release", "create", plan["tag"], "--repo", plan["repository"], + "--verify-tag", "--latest=false", "--title", plan["tag"], + "--notes-file", str(notes), + ] + if plan["mode"] == "rehearse": + command.append("--prerelease") + command.extend(str(pathlib.Path(directory) / item["name"]) for item in assets) + run(command) + plan.update(status="verifying", assets=assets) + check_public_assets(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", "verifying"} or cycle.state["commit"] != plan["commit"]: + raise ValueError("Cycle changed before recording publication") + cycle.save(plan) + return {**plan, "action": "verify"} + + +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.get("commit") != plan["commit"] or cycle.state["status"] != "verifying": + raise ValueError("No matching public release awaiting successful verification") + assert_tag(api, plan) + 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 + if mode == "rehearse" and not api.request(f"repos/{target}").get("fork"): + raise ValueError("Downstream rehearsal notification requires an actual fork") + 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 not in {"execute", "rehearse"}: + 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") + elif not metadata.get("fork") or repository.split("/")[0] == "notaryproject" or os.environ.get("MONTHLY_PATCH_REHEARSAL_ENABLED") != "true": + raise ValueError("Release rehearsal notifications require an enabled fork") + 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 00000000..18f55bf0 --- /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_fork_propagation.py b/.github/scripts/notation_fork_propagation.py new file mode 100644 index 00000000..51981c3a --- /dev/null +++ b/.github/scripts/notation_fork_propagation.py @@ -0,0 +1,200 @@ +# 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. + +"""Open signed, plan-bound fork replacement PRs without bypassing normal CI.""" + +import argparse +import base64 +import hashlib +import json +import os +import pathlib +import tempfile + +import monthly_release as release +import notation_release_controller as controller + + +def module_paths(repository): + return {f"{directory}/go.{extension}".removeprefix("./") + for directory in release.modules(repository) for extension in ("mod", "sum")} + + +def source_head(api, plan, authorization): + cycle = release.Cycle(api, plan["repository"], plan["month"], "rehearse") + if cycle.state is None or cycle.state["status"] != "waiting": + raise ValueError("Fork propagation requires a trusted waiting worker cycle") + controller.worker_authorization(api, plan["repository"], plan["month"], cycle.state) + head = api.request(f"repos/{plan['repository']}/branches/{plan['main']}")["commit"]["sha"] + expected = authorization["snapshot"]["main_head"] + if head != expected: + ahead = controller.compare_commits(api, plan["repository"], expected, head) + merged = cycle.state["merged"] + checkpoint = next((index for index, item in enumerate(merged) if item["commit"] == expected), -1) + approved = {item["commit"] for item in merged[checkpoint + 1:]} + if ahead != approved: + raise ValueError("Isolated main advanced outside the approved dependency merges") + return head + + +def manifests(source, repository): + return {directory: json.loads(release.run(["go", "mod", "edit", "-json"], source / directory)) + for directory in release.modules(repository)} + + +def prepare(api, plan, output): + authorization = controller.worker_authorization(api, plan["repository"], plan["month"], plan) + if authorization is None or plan["mode"] != "rehearse": + raise ValueError("Replacement propagation is restricted to authorized fork rehearsals") + producers = authorization["producers"] + if not producers: + return {"action": "none", "reason": "No planned producer update"} + for pull in api.pulls(plan["repository"], plan["main"], "open"): + if ((release.dependabot(pull, plan["main"]) and controller.scoped_pull(api, plan["repository"], pull, authorization)) + or controller.fork_propagation_pull(api, plan["repository"], pull, authorization)): + return {"action": "none", "reason": "Waiting for existing approved dependency PR checks"} + base = source_head(api, plan, authorization) + if not release.producer_lag(api, plan["repository"], base, "rehearse", producers): + return {"action": "none", "reason": "Planned producers are already consumed"} + producer_modules = {name: api.manifest(item["repository"], item["version"], ".") for name, item in producers.items()} + with tempfile.TemporaryDirectory(prefix="notation-fork-propagation-source-") as temporary: + source = pathlib.Path(temporary) + release.git(["init", "--quiet"], source) + release.git(["remote", "add", "origin", f"https://github.com/{plan['repository']}.git"], source) + release.git(["fetch", "--quiet", "--depth=1", "origin", base], source) + release.git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], source) + if release.git(["rev-parse", "HEAD"], source) != base: + raise ValueError("Fork propagation source changed") + before = manifests(source, plan["repository"]) + original = {path: (source / path).read_text(encoding="utf-8") if (source / path).exists() else None + for path in module_paths(plan["repository"])} + for directory in release.modules(plan["repository"]): + for name, item in producers.items(): + module = f"github.com/notaryproject/{name}" + if not any(entry["Path"] == module for entry in before[directory].get("Require") or []): + continue + release.run(["go", "mod", "edit", f"-replace={module}=github.com/{item['repository']}@{item['version']}"], + source / directory) + release.run(["go", "mod", "tidy"], source / directory) + after = manifests(source, plan["repository"]) + current = release.run(["go", "env", "GOVERSION"]).strip() + if not controller.producer_manifests(before, after, producers, producer_modules, current): + raise ValueError("Propagation would change unrelated direct dependencies; finish their reviewed updates first") + if release.producer_lag_from_manifests(plan["repository"], after, "rehearse", producers): + raise ValueError("Propagation did not consume every planned producer") + changed = set(release.git(["diff", "--name-only", "--no-renames"], source).splitlines()) + changed.update(release.git(["ls-files", "--others", "--exclude-standard"], source).splitlines()) + if not changed or not changed <= module_paths(plan["repository"]): + raise ValueError("Propagation changed files outside module manifests") + files = {path: (source / path).read_text(encoding="utf-8") for path in sorted(changed)} + result = {"schema": 1, "action": "pull-request", "plan": plan, "base": base, + "original": {path: hashlib.sha256(value.encode()).hexdigest() if value is not None else None + for path, value in original.items()}, "files": files} + output.mkdir(parents=True, exist_ok=True) + return result + + +def publish(api, prepared, output): + if prepared.get("action") == "none": + return prepared + if set(prepared) != {"schema", "action", "plan", "base", "original", "files"} or type(prepared["schema"]) is not int or prepared["schema"] != 1: + raise ValueError("Invalid propagation artifact") + plan = prepared["plan"] + authorization = controller.worker_authorization(api, plan["repository"], plan["month"], plan) + if authorization is None or plan["mode"] != "rehearse" or source_head(api, plan, authorization) != prepared["base"]: + raise ValueError("Propagation source or authorization changed") + release.policy(plan["repository"], "rehearse", os.environ, api.request(f"repos/{plan['repository']}")) + files, allowed = prepared["files"], module_paths(plan["repository"]) + if (not files or not set(files) <= allowed or set(prepared["original"]) != allowed + or any(not isinstance(value, str) for value in files.values()) + or sum(len(value.encode()) for value in files.values()) > 2_000_000): + raise ValueError("Invalid or oversized propagation file set") + branch = controller.propagation_branch(plan["month"], authorization["plan_id"]) + for pull in api.pulls(plan["repository"], plan["main"], "open"): + if pull["head"].get("ref") == branch: + if not controller.fork_propagation_pull(api, plan["repository"], pull, authorization): + raise ValueError("Existing propagation branch has an untrusted PR") + return {"action": "waiting", "pull_request": pull["number"], "url": pull["html_url"]} + with tempfile.TemporaryDirectory(prefix="notation-fork-propagation-publish-") as temporary: + source = pathlib.Path(temporary) / "source" + source.mkdir() + release.git(["init", "--quiet"], source) + release.git(["remote", "add", "origin", f"https://github.com/{plan['repository']}.git"], source) + release.git(["fetch", "--quiet", "--depth=1", "origin", prepared["base"]], source) + release.git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], source) + if release.git(["rev-parse", "HEAD"], source) != prepared["base"]: + raise ValueError("Propagation publish checkout changed") + before = manifests(source, plan["repository"]) + for path in sorted(allowed): + file = source / path + actual = hashlib.sha256(file.read_bytes()).hexdigest() if file.exists() else None + if actual != prepared["original"][path]: + raise ValueError("Propagation artifact does not match its immutable source") + for path, content in files.items(): + (source / path).write_text(content, encoding="utf-8") + after = manifests(source, plan["repository"]) + producer_modules = {name: api.manifest(item["repository"], item["version"], ".") + for name, item in authorization["producers"].items()} + if not controller.producer_manifests(before, after, authorization["producers"], producer_modules, + release.run(["go", "env", "GOVERSION"]).strip()): + raise ValueError("Propagation artifact escapes its planned producer scope") + if release.producer_lag_from_manifests(plan["repository"], after, "rehearse", authorization["producers"]): + raise ValueError("Propagation artifact does not consume all planned producers") + with tempfile.TemporaryDirectory(prefix="notation-fork-propagation-signing-") as signing: + environment = release.signing_environment(signing, api) + environment["GIT_AUTHOR_DATE"] = environment["GIT_COMMITTER_DATE"] = plan["started_at"] + release.git(["add", "--", *sorted(files)], source, environment) + release.git(["commit", "-S", "-m", "Update approved Notation fork producer versions", + "-m", "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>"], source, environment) + head = release.git(["rev-parse", "HEAD"], source) + existing = api.optional(f"repos/{plan['repository']}/git/ref/heads/{branch}") + if existing and existing["object"]["sha"] != head: + raise ValueError("Propagation branch exists at a different immutable commit") + if not existing: + release.git(["-c", "push.followTags=false", "push", "--atomic", + f"--force-with-lease=refs/heads/{branch}:", "origin", f"{head}:refs/heads/{branch}"], + source, environment) + receipt = {"schema": 1, "repository": plan["repository"], "month": plan["month"], + "controller_issue": authorization["issue"], "plan_id": authorization["plan_id"], + "producers": authorization["producers"], "base": prepared["base"], "head": head} + marker = f"" + pull = api.request(f"repos/{plan['repository']}/pulls", "POST", { + "title": "Update approved Notation fork producer versions", "head": branch, "base": plan["main"], + "maintainer_can_modify": False, + "body": marker + "\n\nFork rehearsal only. Normal successful CI and required reviews must precede merging.\n\n```json\n" + + json.dumps(receipt, indent=2) + "\n```\n", + }) + if pull["user"]["login"] != os.environ["MONTHLY_PATCH_ACTOR"]: + raise ValueError("Propagation PR was created by an unexpected actor") + return {"action": "waiting", "pull_request": pull["number"], "url": pull["html_url"]} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("prepare", "publish")) + parser.add_argument("--plan", type=pathlib.Path) + parser.add_argument("--prepared", type=pathlib.Path) + parser.add_argument("--output", type=pathlib.Path, required=True) + args = parser.parse_args() + api = release.GitHub() + if args.command == "prepare": + if args.plan is None: + parser.error("prepare requires --plan") + result = prepare(api, controller.load_json(args.plan.read_text()), args.output) + else: + if args.prepared is None: + parser.error("publish requires --prepared") + result = publish(api, controller.load_json(args.prepared.read_text()), args.output) + args.output.mkdir(parents=True, exist_ok=True) + (args.output / "propagation.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps({key: value for key, value in result.items() if key not in {"files", "original", "plan"}}, indent=2)) diff --git a/.github/scripts/notation_release_controller.py b/.github/scripts/notation_release_controller.py new file mode 100644 index 00000000..3606d9ed --- /dev/null +++ b/.github/scripts/notation_release_controller.py @@ -0,0 +1,1184 @@ +# 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 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 + +FORK = os.environ.get("GITHUB_REPOSITORY", "") in { + f"yizha1/{name}" for name in release.PROJECTS +} +OWNER = "yizha1" if FORK else "notaryproject" +HOST = f"{OWNER}/notation" +MODE = "rehearse" if FORK else "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" +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 fork_metadata(api, repository): + if repository not in REPOSITORIES: + raise ValueError("Coordinator repository scope is fixed") + metadata = api.request(f"repos/{repository}") + if bool(metadata.get("fork")) != FORK or metadata.get("private") or metadata.get("default_branch") != "main": + raise ValueError(f"Expected a public {'fork' if FORK else 'canonical repository'} with trusted default main: {repository}") + return metadata + + +def installed_worker(api, repository, commit): + files = { + WORKER_PATH: ("MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'", " workflow_dispatch:"), + ".github/scripts/notation_release_controller.py": ("def worker_authorization(",), + ".github/scripts/monthly_release.py": ("coordinator_authorization(",), + } + if not FORK: + files.update({ + ".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 ",), + }) + for path, required in files.items(): + item = api.optional(f"repos/{repository}/contents/{path}?ref={commit}") + if item is None: + return False + if item.get("encoding") != "base64": + raise ValueError(f"Cannot inspect trusted worker installation: {path}") + content = base64.b64decode(item["content"]).decode("utf-8") + if any(token not in content for token in required): + return False + return True + + +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 + if FORK: + raise ValueError("Assessed source refs changed") + 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 FORK: + if commit != snapshot["automation_sha"]: + raise ValueError("Release worker code changed after assessment") + elif automation_digest(api, repository, commit) != snapshot.get("automation_digest"): + raise ValueError("Release worker code changed after assessment; explicit reassessment is required") + return commit + + +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, + "release_votes": {"notation-core-go": "branch-rules", "notation-go": "repo-maintainer-majority", + "notation": "repo-maintainer-majority"}, + } + if FORK or policy != expected or type(policy.get("schema")) is not int or type(policy.get("maximum_assessment_age_hours")) is not int: + raise ValueError("Unsupported automatic-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 authorize_routine(api, plan): + if FORK or os.environ.get("NOTATION_RELEASE_AUTO_APPROVAL_ENABLED") != "true": + raise ValueError("Automatic cycle authorization is disabled") + 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": "policy", "policy_sha256": digest(policy), "policy_commit": commit} + + +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 = fork_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") + if workflow is None or workflow.get("state") != "active" or not installed_worker(api, repository, automation): + blockers.append("Install and enable the complete coordinator-aware release worker on main") + 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 = {} + if not FORK: + 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], + "ready": ready, "reason": reason}) + 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"]) + merged.append(item) + 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, + **({"automation_digest": automation_digest(api, repository, automation)} if not FORK else {}), + **provenance, + "source_repository": source, "source_commit": commit, "blockers": blockers, + "pulls": pulls, "merged": merged, "manifests": manifests, "scans": scans, + } + 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(): + 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 node["workflow_id"] is not None: + positive_id(node["workflow_id"]) + if not FORK and not DIGEST.fullmatch(node.get("automation_digest", "")): + raise ValueError("Missing automation tree identity") + if not FORK and 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) + if set(assessment) != {"schema", "month", "inventory_sha256", "decisions"}: + 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") if not FORK else ()))} + for repo, node in inventory["repositories"].items() + }, + } + # 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"]} 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 {"active", "completed"}: + raise ValueError("Invalid controller status") + if not FORK: + authorization = state.get("authorization", {}) + if authorization.get("kind") not in {"explicit", "policy"}: + raise ValueError("Missing authorization provenance") + if authorization["kind"] == "policy" and ( + not DIGEST.fullmatch(authorization.get("policy_sha256", "")) + or not release.SHA.fullmatch(authorization.get("policy_commit", "")) + ): + raise ValueError("Invalid policy authorization") + 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] + 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 "" + if marker not in (pull.get("body") or ""): + return False + commit = api.request(f"repos/{repository}/commits/{receipt['head']}") + if not commit["commit"]["verification"]["verified"] or (commit.get("author") or {}).get("login") != os.environ["MONTHLY_PATCH_ACTOR"]: + raise ValueError("Fork propagation PR requires a verified commit by the configured actor") + scoped = copy.deepcopy(pull) + scoped["base"]["sha"] = receipt["base"] + return producer_pull(api, repository, scoped, authorization["producers"]) + + +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 fork_propagation_pull(api, repository, pull, authorization) + 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 = fork_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") + if not FORK: + assessed_automation(api, HOST, expected["snapshots"][HOST], run["head_sha"]) + return expected + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["collect", "validate", "control"]) + 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 == "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 not FORK and os.environ.get("MONTHLY_PATCH_ENABLED") != "true": + raise ValueError("Canonical monthly release execution is disabled") + if args.assessment_run is None and os.environ.get("NOTATION_RELEASE_AUTO_APPROVAL_ENABLED") == "true": + 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"] == "active"] + 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: + authorization = {"kind": "explicit"} if args.approve else authorize_routine(api, plan) + issue, state = controller.approve(plan, args.assessment_run, args.replan, args.recover_public_verification, authorization) + 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"] == "active"] + 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 00000000..d9610169 --- /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/test_monthly_public_release.py b/.github/scripts/test_monthly_public_release.py new file mode 100644 index 00000000..d49c394c --- /dev/null +++ b/.github/scripts/test_monthly_public_release.py @@ -0,0 +1,86 @@ +# 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 pathlib +import urllib.request + +from monthly_cli_smoke import smoke +from monthly_release import GitHub, assert_tag, check_public_assets, git, validate_plan +from monthly_release_checks import ( + asset_names, checked, extract_binary, library_source_archive, packaged_e2e, scan, + verify_checksums, verify_metadata, +) + + +def verify(plan, source, platform, evidence): + validate_plan(plan) + if plan["status"] != "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_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: + 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() + expected = recorded[name] + 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, + "public_downloads": True, "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 00000000..c35ae8ad --- /dev/null +++ b/.github/scripts/test_monthly_release.py @@ -0,0 +1,707 @@ +# 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_REHEARSAL_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 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["state"], + "user": {"login": "test-actor"}, + } + 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) + + 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_rehearsal_bumps_above_all_same_line_trial_tags(self): + candidate = release.baseline_plan(FakeGitHub(), "yizha1/notation-core-go", "2026-10", "rehearse") + self.assertEqual(candidate["tag"], "v1.3.4-monthly-test.202610") + self.assertEqual(candidate["branch"], "monthly-patch-test-release-1.3") + release.validate_plan(candidate) + + 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: + if name == "MONTHLY_PATCH_REHEARSAL_ENABLED": + continue + 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("yizha1/notation", "rehearse", ENVIRONMENT, {"fork": True}) + 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") + self.assertEqual(release.requirement(manifest, module, "yizha1/notation-core-go", "rehearse"), replacement["New"]["Version"]) + 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.fork_propagation_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])), []) + fork = FakeGitHub() + list(release.dispatch_consumers(fork, "yizha1/notation-core-go", "rehearse", "v1.3.4-monthly-test.202610")) + self.assertTrue(all(path.startswith("repos/yizha1/") for path, _, _ in fork.writes)) + with self.assertRaises(ValueError): + list(release.dispatch_consumers(api, REPO, "execute", "v1.3.1", ["untrusted/notation"])) + with patch.object(api, "request", side_effect=release.APIError("HTTP 403")), self.assertRaises(release.APIError): + list(release.dispatch_consumers(api, REPO, "execute", "v1.3.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"), "assets": [ + {"name": name, "size": 10, "sha256": "a" * 64} + for name in ("notation-core-go_1.3.1_source.tar.gz", "notation-core-go_1.3.1_checksums.txt") + ]} + public = {"id": 1, "draft": False, "prerelease": False, "body": body(candidate)} + assets = [{"name": item["name"], "size": 10, "digest": "sha256:" + "a" * 64} for item in candidate["assets"]] + asset = assets[0] + with 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(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(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 00000000..8c7c20d5 --- /dev/null +++ b/.github/scripts/test_monthly_release_checks.py @@ -0,0 +1,234 @@ +# 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_advisory_dispositions_are_explicit_scoped_expiring_and_revision_pinned(self): + policy = { + "advisory": "GO-2026-1234", "repository": "yizha1/notation", "line": "1.3", + "owner": "reviewer", "expires": "2099-01-01", "modified": "2026-01-01", + "reason": "Reviewed test condition", "reference": "https://example.com/advisory", + } + advisory = {"modified": "2026-01-01", "affected": [{"ranges": [{"events": [{"introduced": "0"}]}]}]} + self.assertEqual(checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": advisory}), set()) + context = {"GITHUB_REPOSITORY": "yizha1/notation", "MONTHLY_RELEASE_TAG": "v1.3.1", + "MONTHLY_PATCH_ADVISORY_DISPOSITIONS": json.dumps([policy])} + with patch.dict(os.environ, context): + self.assertEqual(checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": advisory}), {"GO-2026-1234"}) + for field, value in (("repository", "other/notation"), ("line", "1.2"), ("expires", "2000-01-01"), ("modified", "changed")): + with patch.dict(os.environ, {**context, "MONTHLY_PATCH_ADVISORY_DISPOSITIONS": json.dumps([{**policy, field: value}])}), self.assertRaises(ValueError): + checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": advisory}) + fixed = {**advisory, "affected": [{"ranges": [{"events": [{"fixed": "1.3.2"}]}]}]} + with patch.dict(os.environ, context), self.assertRaises(ValueError): + checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": fixed}) + + 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-release.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("permissions:\n 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 00000000..0bed6300 --- /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 00000000..e94b0e5d --- /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_fork_release.py b/.github/scripts/test_notation_fork_release.py new file mode 100644 index 00000000..818db5fe --- /dev/null +++ b/.github/scripts/test_notation_fork_release.py @@ -0,0 +1,490 @@ +# 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 tempfile +import unittest +import zipfile +from unittest.mock import patch + +import monthly_release as release +import check_notation_fork_setup as setup +import notation_fork_propagation as propagation +import notation_release_controller as controller +from test_monthly_release import plan as worker_plan, pull +from test_notation_release_controller import ControllerAPI, CORE, GO, CLI, MAIN, PR_HEAD, MONTH + +PLAN_ID = "1" * 64 +TARGET = "v1.3.4-monthly-test.202610" + + +def authorization(): + return {"issue": 12, "plan_id": PLAN_ID, + "snapshot": {"main": "monthly-patch-test-main", "main_head": MAIN, "tag": TARGET}, + "producers": {"notation-core-go": {"repository": CORE, "version": TARGET}}} + + +def producer_pr(): + candidate = pull(number=99, sha=PR_HEAD, base="monthly-patch-test-main") + candidate["user"] = {"login": "test-actor", "type": "User"} + candidate["base"]["sha"] = MAIN + candidate["head"].update(repo={"full_name": GO}, ref=controller.propagation_branch(MONTH, PLAN_ID)) + receipt = {"schema": 1, "repository": GO, "month": MONTH, "controller_issue": 12, "plan_id": PLAN_ID, + "producers": authorization()["producers"], "base": MAIN, "head": PR_HEAD} + candidate["body"] = f"\n```json\n{json.dumps(receipt)}\n```\n" + return candidate + + +class PropagationAuthorizationTests(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.commit = patch.object(self.api, "request", return_value={ + "commit": {"verification": {"verified": True}}, "author": {"login": "test-actor"}}) + self.commit.start() + self.addCleanup(self.commit.stop) + self.scope = patch.object(controller, "producer_pull", return_value=True) + self.scope.start() + self.addCleanup(self.scope.stop) + + def test_only_signed_actor_pr_with_current_exact_plan_is_accepted(self): + self.assertTrue(controller.fork_propagation_pull(self.api, GO, producer_pr(), authorization())) + for mutate in ( + lambda pr: pr["user"].update(login="different-actor"), + lambda pr: pr["head"].update(sha=MAIN), + lambda pr: pr["head"].update(ref="main"), + lambda pr: pr["head"].update(repo={"full_name": "other/notation-go"}), + lambda pr: pr["base"].update(ref="main"), + lambda pr: pr.update(body="ordinary manually authored dependency update"), + ): + candidate = producer_pr() + mutate(candidate) + with self.subTest(candidate=candidate): + self.assertFalse(controller.fork_propagation_pull(self.api, GO, candidate, authorization())) + self.assertFalse(controller.fork_propagation_pull(self.api, "notaryproject/notation-go", producer_pr(), authorization())) + self.assertFalse(controller.fork_propagation_pull(self.api, GO, producer_pr(), {**authorization(), "plan_id": "2" * 64})) + + def test_unsigned_or_wrong_signer_commit_blocks_merging(self): + for verified, actor in ((False, "test-actor"), (True, "different-actor")): + self.api.request.return_value = {"commit": {"verification": {"verified": verified}}, "author": {"login": actor}} + with self.subTest(verified=verified, actor=actor), self.assertRaisesRegex(ValueError, "verified commit"): + controller.fork_propagation_pull(self.api, GO, producer_pr(), authorization()) + + def test_signed_pr_cannot_hide_unrelated_dependency_changes(self): + controller.producer_pull.return_value = False + self.assertFalse(controller.fork_propagation_pull(self.api, GO, producer_pr(), authorization())) + + def test_closed_pr_uses_immutable_receipt_base_for_scope_validation(self): + candidate = producer_pr() + candidate["base"]["sha"] = "e" * 40 + candidate.update(merged_at="2026-10-05T01:00:00Z", merge_commit_sha="f" * 40) + self.assertTrue(controller.fork_propagation_pull(self.api, GO, candidate, authorization())) + self.assertEqual(controller.producer_pull.call_args.args[2]["base"]["sha"], MAIN) + + def test_new_authorized_actor_pr_can_enter_worker_inventory(self): + candidate = producer_pr() + candidate.update(merged_at="2026-10-05T01:00:00Z", merge_commit_sha="f" * 40) + self.api.closed_pulls = [candidate] + self.assertEqual(release.inventory(self.api, GO, "monthly-patch-test-main", authorization())[0]["number"], 99) + self.assertEqual(release.inventory(self.api, GO, "monthly-patch-test-main"), []) + + def test_plan_without_producers_never_authorizes_actor_updates(self): + self.assertFalse(controller.fork_propagation_pull(self.api, GO, producer_pr(), {**authorization(), "producers": {}})) + + +class PropagationPipelineTests(unittest.TestCase): + def test_real_go_update_creates_one_signed_module_only_pr_and_resumes(self): + self.check_real_go_update() + + def test_real_go_update_includes_a_producer_required_direct_dependency_bump(self): + self.check_real_go_update(direct_dependency=True) + + def check_real_go_update(self, direct_dependency=False): + with tempfile.TemporaryDirectory(prefix="notation-fork-integration-") as temporary: + root = pathlib.Path(temporary) + proxy, tree, remote = root / "proxy", root / "tree", root / "remote.git" + tree.mkdir() + old = "v1.3.3-monthly-test.202609" + module = "github.com/yizha1/notation-core-go" + versions = proxy / module / "@v" + versions.mkdir(parents=True) + for version in (old, TARGET): + mod = "module github.com/notaryproject/notation-core-go\n\ngo 1.26.0\n" + if direct_dependency: + mod += "\nrequire example.com/shared " + ("v1.0.0" if version == old else "v1.2.0") + "\n" + (versions / (version + ".mod")).write_text(mod) + (versions / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-01T00:00:00Z"})) + with zipfile.ZipFile(versions / (version + ".zip"), "w") as archive: + archive.writestr(module + "@" + version + "/go.mod", mod) + body = ('package core\n\nimport "example.com/shared"\n\nfunc Value() int { return shared.Value() }\n' + if direct_dependency else "package core\n\nfunc Value() int { return 1 }\n") + archive.writestr(module + "@" + version + "/core.go", body) + if direct_dependency: + shared = proxy / "example.com/shared" / "@v" + shared.mkdir(parents=True) + for version in ("v1.0.0", "v1.2.0"): + mod = "module example.com/shared\n\ngo 1.26.0\n" + (shared / (version + ".mod")).write_text(mod) + (shared / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-01T00:00:00Z"})) + with zipfile.ZipFile(shared / (version + ".zip"), "w") as archive: + archive.writestr("example.com/shared@" + version + "/go.mod", mod) + archive.writestr("example.com/shared@" + version + "/shared.go", "package shared\n\nfunc Value() int { return 1 }\n") + (tree / "go.mod").write_text( + "module github.com/notaryproject/notation-go\n\ngo 1.26.0\n\n" + "require github.com/notaryproject/notation-core-go v1.3.0\n\n" + f"replace github.com/notaryproject/notation-core-go => {module} {old}\n" + ) + (tree / "main.go").write_text( + 'package notation\n\nimport core "github.com/notaryproject/notation-core-go"\n\nfunc Value() int { return core.Value() }\n' + ) + if direct_dependency: + (tree / "go.mod").write_text((tree / "go.mod").read_text() + "\nrequire example.com/shared v1.0.0\n") + (tree / "main.go").write_text( + 'package notation\n\nimport (core "github.com/notaryproject/notation-core-go"; "example.com/shared")\n\n' + "func Value() int { return core.Value() + shared.Value() }\n" + ) + environment = {"GOWORK": "off", "GOTOOLCHAIN": "local", "GOPATH": str(root / "go"), + "GOMODCACHE": str(root / "modules"), "GOPROXY": proxy.as_uri(), "GOSUMDB": "off", + "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", + "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", "MONTHLY_PATCH_TOKEN_READY": "true", + "MONTHLY_PATCH_REHEARSAL_ENABLED": "true"} + with patch.dict(os.environ, environment): + release.run(["go", "mod", "tidy"], tree) + release.run(["git", "init", "--quiet"], tree) + release.run(["git", "add", "."], tree) + release.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Baseline"], tree) + base = release.run(["git", "rev-parse", "HEAD"], tree).strip() + release.run(["git", "init", "--bare", "--quiet", str(remote)]) + release.run(["git", "push", "--quiet", str(remote), "HEAD:refs/heads/monthly-patch-test-main"], tree) + key = root / "key" + release.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)]) + public = key.with_suffix(".pub").read_text() + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + auth = authorization() + auth["snapshot"]["main_head"] = base + plan.update(controller_issue=12, controller_plan_id=PLAN_ID, producers=auth["producers"]) + before = propagation.manifests(tree, GO)["."] + + class LocalAPI(ControllerAPI): + def optional(self, path): + if "/git/ref/heads/" in path: + return None + return super().optional(path) + + def request(self, path, method="GET", payload=None): + if "/branches/" in path: + return {"commit": {"sha": base}} + if method == "POST" and path.endswith("/pulls"): + self.writes.append((path, method, payload)) + head = release.run(["git", f"--git-dir={remote}", "rev-parse", payload["head"]]).strip() + item = {"number": 99, "html_url": "https://github.com/yizha1/notation-go/pull/99", + "user": {"login": "test-actor"}, "body": payload["body"], + "base": {"ref": plan["main"], "sha": base}, + "head": {"ref": payload["head"], "sha": head, "repo": {"full_name": GO}}} + self.open_pulls.append(item) + return item + if "/commits/" in path: + return {"commit": {"verification": {"verified": True}}, "author": {"login": "test-actor"}} + return super().request(path, method, payload) + + def manifest(self, repository, ref, directory): + if repository == CORE: + return {"Go": "1.26.0", "Require": [{"Path": "example.com/shared", "Version": "v1.2.0"}] + if direct_dependency else []} + if ref == base: + return before + modfile = root / "remote.mod" + modfile.write_text(release.run(["git", f"--git-dir={remote}", "show", ref + ":go.mod"])) + return json.loads(release.run(["go", "mod", "edit", "-json", "-modfile=" + str(modfile)])) + + api = LocalAPI() + api.public_key = public + actual_git = release.git + def local_git(command, directory, environment=None): + if command[:3] == ["remote", "add", "origin"]: + command = [*command[:3], str(remote)] + return actual_git(command, directory, environment) + with patch.dict(os.environ, {"MONTHLY_PATCH_SIGNING_KEY": key.read_text()}), patch.object( + controller, "worker_authorization", return_value=auth + ), patch.object(release, "Cycle") as cycle, patch.object(release, "git", side_effect=local_git): + cycle.return_value.state = plan + prepared = propagation.prepare(api, plan, root / "prepared") + self.assertEqual(set(prepared["files"]), {"go.mod", "go.sum"}) + result = propagation.publish(api, prepared, root / "published") + self.assertEqual(result["pull_request"], 99) + head = api.open_pulls[0]["head"]["sha"] + if direct_dependency: + requirements = {entry["Path"]: entry["Version"] for entry in api.manifest(GO, head, ".")["Require"]} + self.assertEqual(requirements["example.com/shared"], "v1.2.0") + signers = root / "allowed-signers" + signers.write_text("test@example.com " + public) + release.run(["git", "-c", "gpg.ssh.allowedSignersFile=" + str(signers), + f"--git-dir={remote}", "verify-commit", head]) + names = release.run(["git", f"--git-dir={remote}", "diff", "--name-only", base, head]).splitlines() + self.assertEqual(set(names), {"go.mod", "go.sum"}) + self.assertEqual(propagation.publish(api, prepared, root / "retry")["pull_request"], 99) + self.assertEqual(len(api.writes), 1) + + def test_direct_dependency_changes_are_limited_to_the_producer_required_floor(self): + before = {"Go": "1.26.0", + "Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.0"}, + {"Path": "example.com/shared", "Version": "v1.0.0"}], + "Replace": [{"Old": {"Path": "github.com/notaryproject/notation-core-go"}, + "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.1-trial.4"}}]} + after = copy.deepcopy(before) + after["Replace"][0]["New"]["Version"] = TARGET + metadata = {"notation-core-go": {"Go": "1.26.0", + "Require": [{"Path": "example.com/shared", "Version": "v1.2.0"}]}} + for version, expected in (("v1.0.0", False), ("v1.1.0", False), ("v1.2.0", True), ("v1.3.0", False)): + after["Require"][1]["Version"] = version + with self.subTest(version=version): + self.assertEqual(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], + metadata, "go1.27.1"), expected) + before["Require"][1]["Version"] = "v1.3.0" + after["Require"][1]["Version"] = "v1.2.0" + self.assertFalse(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], + metadata, "go1.27.1")) + after["Require"][1]["Version"] = "v1.3.0" + self.assertTrue(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], + metadata, "go1.27.1")) + before["Require"][1]["Version"] = "v1.0.0" + after["Require"][1]["Version"] = "v1.2.0" + after["Require"].append({"Path": "example.com/unrelated", "Version": "v1.0.0"}) + self.assertFalse(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], + metadata, "go1.27.1")) + after["Require"].pop() + after["Replace"][0]["New"]["Version"] = before["Replace"][0]["New"]["Version"] + self.assertFalse(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], + metadata, "go1.27.1")) + + def test_module_version_ordering_matches_go_semver_precedence(self): + ordered = ["v1.0.0-alpha", "v1.0.0-alpha.1", "v1.0.0-alpha.beta", "v1.0.0-beta", + "v1.0.0-beta.2", "v1.0.0-beta.11", "v1.0.0-rc.1", "v1.0.0", "v1.2.0", "v1.10.0"] + self.assertEqual(sorted(reversed(ordered), key=controller.module_version), ordered) + self.assertEqual(controller.module_version("v2.0.0+incompatible"), controller.module_version("v2.0.0")) + for invalid in ("v01.0.0", "v1.0.0-01", "v1.0", "v1.0.0+", None): + with self.subTest(version=invalid), self.assertRaises(ValueError): + controller.module_version(invalid) + + def test_shared_dependency_uses_highest_changed_producer_requirement(self): + producers = {name: {"repository": f"yizha1/{name}", "version": TARGET} + for name in ("notation-core-go", "notation-go")} + before = {"Go": "1.26.0", + "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} for name in producers] + + [{"Path": "example.com/shared", "Version": "v1.0.0"}], + "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, + "New": {"Path": f"github.com/yizha1/{name}", "Version": "v1.3.1-trial.4"}} + for name in producers]} + after = copy.deepcopy(before) + for replacement in after["Replace"]: + replacement["New"]["Version"] = TARGET + metadata = {name: {"Go": "1.26.0", "Require": [{"Path": "example.com/shared", "Version": version}]} + for name, version in zip(producers, ("v1.10.0", "v1.2.0"))} + for version, expected in (("v1.2.0", False), ("v1.10.0", True)): + after["Require"][-1]["Version"] = version + with self.subTest(version=version): + self.assertEqual(controller.producer_manifests({".": before}, {".": after}, producers, metadata, "go1.27.1"), + expected) + + def test_source_head_must_contain_only_recorded_approved_merges(self): + api = ControllerAPI() + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + plan["merged"] = [{"number": 1, "commit": "c" * 40}] + with patch.object(release, "Cycle") as cycle, patch.object(controller, "worker_authorization"), patch.object( + controller, "compare_commits", return_value={"d" * 40} + ), self.assertRaisesRegex(ValueError, "outside the approved"): + cycle.return_value.state = plan + propagation.source_head(api, plan, {**authorization(), "snapshot": {**authorization()["snapshot"], "main_head": PR_HEAD}}) + + def test_no_plan_and_no_producer_cannot_generate_updates(self): + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + with tempfile.TemporaryDirectory() as temporary, patch.object(controller, "worker_authorization", return_value=None): + with self.assertRaisesRegex(ValueError, "authorized fork"): + propagation.prepare(ControllerAPI(), plan, pathlib.Path(temporary)) + with tempfile.TemporaryDirectory() as temporary, patch.object( + controller, "worker_authorization", return_value={**authorization(), "producers": {}} + ): + self.assertEqual(propagation.prepare(ControllerAPI(), plan, pathlib.Path(temporary))["action"], "none") + + def test_existing_approved_dependency_pr_waits_without_duplicate_creation(self): + api = ControllerAPI() + api.open_pulls = [pull(base="monthly-patch-test-main")] + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + with tempfile.TemporaryDirectory() as temporary, patch.object( + controller, "worker_authorization", return_value=authorization() + ), patch.object(controller, "scoped_pull", return_value=True), patch.object(propagation, "source_head") as head: + result = propagation.prepare(api, plan, pathlib.Path(temporary)) + self.assertEqual(result["action"], "none") + head.assert_not_called() + self.assertEqual(api.writes, []) + + def test_malformed_and_out_of_scope_artifacts_fail_before_checkout(self): + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + valid = {"schema": 1, "action": "pull-request", "plan": plan, "base": MAIN, + "original": {name: None for name in propagation.module_paths(GO)}, + "files": {"go.mod": "module example\n"}} + for artifact in ( + {**valid, "schema": True}, + {**valid, "files": {"../../outside": "content"}}, + {**valid, "files": {"go.mod": 42}}, + {**valid, "files": {"go.mod": "x" * 2_000_001}}, + {**valid, "original": {}}, + ): + with tempfile.TemporaryDirectory() as temporary, patch.object( + controller, "worker_authorization", return_value=authorization() + ), patch.object(propagation, "source_head", return_value=MAIN), patch.object(release, "policy"), patch.object( + release, "git" + ) as git, self.subTest(artifact=list(artifact)), self.assertRaises(ValueError): + propagation.publish(ControllerAPI(), artifact, pathlib.Path(temporary)) + git.assert_not_called() + + def test_source_job_has_no_signing_or_write_credential(self): + workflow = pathlib.Path(__file__).parent.parent / "workflows/monthly-patch-release.yml" + source = workflow.read_text().split(" package:\n", 1)[1].split(" publish:\n", 1)[0] + self.assertNotIn("secrets.", source) + self.assertNotIn("MONTHLY_PATCH_SIGNING_KEY", source) + publisher = workflow.read_text().split(" publish:\n", 1)[1].split(" verify:\n", 1)[0] + self.assertNotIn("go mod tidy", publisher) + self.assertIn("monthly_release.py publish", publisher) + self.assertNotIn("build_monthly_assets.py", publisher) + + def test_fork_ci_covers_same_repo_prs_and_uses_no_release_secrets(self): + workflow = (pathlib.Path(__file__).parent.parent / "workflows/notation-release-validation.yml").read_text() + self.assertIn(" pull_request:", workflow) + self.assertIn("test_upstream_authorization.py", workflow) + self.assertNotIn("secrets.", workflow) + self.assertNotIn("head.repo.full_name !=", workflow) + + +class SetupReadinessTests(unittest.TestCase): + def setUp(self): + class SetupAPI(ControllerAPI): + def __init__(self): + super().__init__() + self.missing = set() + self.secrets = {repo: ["MONTHLY_PATCH_TOKEN", "MONTHLY_PATCH_SIGNING_KEY"] + for repo in controller.REPOSITORIES} + self.secrets[CLI].append("COPILOT_GITHUB_TOKEN") + self.variables = {repo: { + "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", + "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", "MONTHLY_PATCH_REHEARSAL_ENABLED": "true", + "NOTATION_RELEASE_COORDINATOR_ENABLED": "true", + } for repo in controller.REPOSITORIES} + + def request(self, path, method="GET", payload=None): + repository = "/".join(path.split("/")[1:3]) + if "/actions/secrets?" in path: + entries = [{"name": name} for name in self.secrets[repository]] + return {"total_count": len(entries), "secrets": entries} + if "/actions/variables?" in path: + entries = [{"name": name, "value": value} for name, value in self.variables[repository].items()] + return {"total_count": len(entries), "variables": entries} + return super().request(path, method, payload) + + def optional(self, path): + if any(value in path for value in self.missing): + return None + if "contents/" in path: + value = ("target-branch: monthly-patch-test-main\n" if "dependabot.yml" in path + else " pull_request:\nqualify_monthly_candidate.py\n") + return {"encoding": "base64", "content": base64.b64encode(value.encode()).decode()} + return self.request(path) + + def manifest(self, repository, commit, directory="."): + producers = release.PROJECTS[repository.split("/")[1]] + return { + "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} + for name in producers], + "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, + "New": {"Path": f"github.com/yizha1/{name}", "Version": TARGET}} + for name in producers], + } + + self.api = SetupAPI() + for name, value in ( + ("fork_metadata", {"has_issues": True}), + ("installed_worker", True), + ): + mocked = patch.object(controller, name, return_value=value) + mocked.start() + self.addCleanup(mocked.stop) + for name, value in ( + ("baseline_plan", {"main": "monthly-patch-test-main", "branch": "monthly-patch-test-release-1.3", + "baseline": "v1.3.0"}), + ("check_publisher_guard", None), + ): + mocked = patch.object(release, name, return_value=value) + mocked.start() + self.addCleanup(mocked.stop) + + def test_complete_fork_setup_is_ready_without_writes(self): + result = setup.check(self.api, MONTH, dependabot_confirmed=True) + self.assertTrue(result["ready"]) + self.assertEqual(set(result["repositories"]), set(controller.REPOSITORIES)) + self.assertEqual(self.api.writes, []) + + def test_configuration_does_not_prove_dependabot_enabled_on_a_fork(self): + result = setup.check(self.api, MONTH) + self.assertFalse(result["ready"]) + for state in result["repositories"].values(): + self.assertEqual(len(state["blockers"]), 1) + self.assertIn("Enable Dependabot version updates", state["blockers"][0]) + + def test_missing_branch_ci_and_token_are_reported_together(self): + self.api.missing.update((f"repos/{CORE}/contents/.github/workflows/notation-fork-ci.yml", + f"repos/{CLI}/branches/monthly-patch-test-release-1.3")) + self.api.secrets[GO].remove("MONTHLY_PATCH_TOKEN") + result = setup.check(self.api, MONTH, dependabot_confirmed=True) + self.assertFalse(result["ready"]) + self.assertEqual(len(result["repositories"][CORE]["blockers"]), 2) + self.assertIn("Install Actions secret MONTHLY_PATCH_TOKEN", result["repositories"][GO]["blockers"]) + self.assertIn("Create reviewed branch monthly-patch-test-release-1.3", result["repositories"][CLI]["blockers"]) + + def test_actor_and_signer_must_match_the_coordinator(self): + self.api.variables[CORE]["MONTHLY_PATCH_ACTOR"] = "other-actor" + self.api.variables[CORE]["MONTHLY_PATCH_SIGNER_LOGIN"] = "other-actor" + self.api.variables[GO]["MONTHLY_PATCH_SIGNER_LOGIN"] = "different-signer" + result = setup.check(self.api, MONTH, dependabot_confirmed=True) + self.assertFalse(result["ready"]) + self.assertIn("Use the same MONTHLY_PATCH_ACTOR as the CLI coordinator", result["repositories"][CORE]["blockers"]) + self.assertIn("Use the actor's signing identity for fork producer PRs", result["repositories"][GO]["blockers"]) + + def test_metadata_collection_paginates_without_reading_secret_values(self): + class MetadataAPI: + def __init__(self): + self.paths = [] + def request(self, path): + self.paths.append(path) + page = int(path.rsplit("=", 1)[1]) + names = range(100) if page == 1 else range(100, 101) + return {"total_count": 101, "secrets": [{"name": f"SECRET_{number}"} for number in names]} + api = MetadataAPI() + self.assertEqual(len(setup.named_metadata(api, "repos/example/repo/actions/secrets", "secrets")), 101) + self.assertEqual(len(api.paths), 2) + self.assertTrue(all("/actions/secrets?" in path for path in api.paths)) + + def test_incomplete_setup_metadata_fails_explicitly(self): + api = ControllerAPI() + with patch.object(api, "request", return_value={"total_count": 2, "secrets": [{"name": "ONE"}]}), self.assertRaisesRegex(ValueError, "Incomplete"): + setup.named_metadata(api, "repos/example/repo/actions/secrets", "secrets") + + +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 00000000..6c5c25ac --- /dev/null +++ b/.github/scripts/test_notation_release_controller.py @@ -0,0 +1,826 @@ +# 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 +import notation_fork_propagation as propagation +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 = (character * 40 for character in "abcd") +MONTH = "2026-10" + + +def inventory(changes=()): + result = { + "schema": 1, "host": coordinator.HOST, "month": MONTH, "collected_at": coordinator.utc_now(), + "repositories": {}, + } + for index, repository in enumerate(coordinator.REPOSITORIES): + result["repositories"][repository] = { + "baseline": "v1.3.0", "main": "monthly-patch-test-main", "branch": "monthly-patch-test-release-1.3", + "tag": "v1.3.4-monthly-test.202610", "main_head": MAIN, "release_head": BRANCH, + "automation_sha": AUTOMATION, "workflow_id": index + 10, + "source_repository": repository, "source_commit": BRANCH, "blockers": [], + "pulls": [{"number": 1, "head": PR_HEAD, "title": "Dependency update", "body": "Untrusted text", + "ready": True, "reason": ""}] if repository in changes else [], + "merged": [], "manifests": {directory: {} for directory in release.modules(repository)}, + "scans": {directory: {"reachable": [], "informational": [], "advisories": {}} + for directory in release.modules(repository)}, + } + return result + + +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 ControllerAPI(FakeGitHub): + def __init__(self): + super().__init__() + self.issues_by_repo = {repo: [] for repo in coordinator.REPOSITORIES} + self.workflow_runs = {} + self.dispatched = [] + self.changed = {} + + def request(self, path, method="GET", payload=None): + repository = "/".join(path.split("/")[1:3]) + suffix = "/".join(path.split("/")[3:]) + if suffix == "issues" and method == "POST" or suffix.startswith("issues/") and method == "PATCH": + return super().request(path, method, payload) + if method == "POST" and (suffix == "dispatches" or suffix.endswith("/dispatches")): + self.dispatched.append((repository, payload)) + self.writes.append((path, method, payload)) + return None + if method != "GET": + raise AssertionError((path, method)) + if not suffix: + return {"fork": True, "private": False, "default_branch": "main", "has_issues": True} + if suffix.startswith("issues/"): + return next(item for item in self.issues_by_repo[repository] if item["number"] == int(suffix.rsplit("/", 1)[1])) + if suffix.startswith("branches/"): + name = suffix.removeprefix("branches/") + value = AUTOMATION if name == "main" else MAIN if name.endswith("-main") else BRANCH + return {"commit": {"sha": self.changed.get((repository, name), value)}} + if suffix == "actions/workflows/monthly-patch-release.yml": + return {"id": coordinator.REPOSITORIES.index(repository) + 10, "state": "active"} + if "/runs?" in suffix: + return {"workflow_runs": list(self.workflow_runs.get(repository, {}).values())} + if suffix.startswith("actions/runs/"): + return self.workflow_runs[repository][int(suffix.rsplit("/", 1)[1])] + return super().request(path, method, payload) + + def worker(self, repository, issue, state, status="published", conclusion="success"): + node = state["nodes"][repository] + run_id = len(self.workflow_runs) + 100 + run = { + "id": run_id, "actor": {"login": "test-actor"}, "head_sha": AUTOMATION, + "workflow_id": coordinator.REPOSITORIES.index(repository) + 10, + "event": "workflow_dispatch", "status": "completed", "conclusion": conclusion, + "display_title": coordinator.run_title(MONTH, state["plan"]["plan_id"], issue["number"], node["attempt"]), + } + self.workflow_runs.setdefault(repository, {})[run_id] = run + candidate = worker_plan(repository, "rehearse", MONTH, status) + candidate.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], + producers={}, assets=[]) + parents = [name for name in release.PROJECTS[repository.split("/")[1]] + if state["nodes"][f"yizha1/{name}"]["status"] == "published"] + for directory in release.modules(repository): + self.manifests[(repository, candidate["commit"], directory)] = { + "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} for name in parents], + "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, + "New": {"Path": f"github.com/yizha1/{name}", "Version": state["nodes"][f"yizha1/{name}"]["version"]}} + for name in parents], + } + existing = [issue for issue in self.issues_by_repo[repository] if coordinator.decode_issue(issue, "test-actor")] + number = max([issue["number"] for issue in existing] + [0]) + 1 + self.issues_by_repo[repository] = existing + [ + {"number": number, "state": "closed" if status in release.FINAL else "open", + "user": {"login": "test-actor"}, "body": body(candidate)} + ] + return run + + +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"], "notaryproject/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"] = ["yizha1/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: " workflow_dispatch:\nMONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'\n", + ".github/scripts/notation_release_controller.py": "def worker_authorization(", + ".github/scripts/monthly_release.py": "coordinator_authorization(", + ".github/scripts/notation_fork_propagation.py": "def prepare(\ndef publish(", + } + def file(path): + self.assertTrue(path.endswith("?ref=" + AUTOMATION)) + name = path.split("/contents/", 1)[1].split("?")[0] + 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 ReleaseControllerTests(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 = coordinator.Controller(self.api, "test-actor") + + def approved(self, changes=(CORE,), decisions=("release", "release", "release")): + evidence = inventory(changes) + plan = coordinator.validate_assessment(evidence, assessment(evidence, decisions)) + return self.controller.approve(plan, 22) + + def test_ordered_releases_are_dispatched_only_after_public_verification(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.assertEqual([repository for repository, _ in self.api.dispatched], [CORE]) + issue, state = self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), 1) + self.api.worker(CORE, issue, state) + with patch.object(release, "assert_tag") as signature, patch.object(release, "check_public_assets") as assets: + issue, state = self.controller.advance(issue, state) + signature.assert_called_once() + assets.assert_called_once() + self.assertEqual([repository for repository, _ in self.api.dispatched], [CORE, GO]) + self.api.worker(GO, issue, state) + issue, state = self.controller.advance(issue, state) + self.assertEqual([repository for repository, _ in self.api.dispatched], [CORE, GO, CLI]) + self.api.worker(CLI, issue, state) + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["status"], "completed") + self.assertEqual(issue["state"], "closed") + self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), 3) + + def test_go_and_cli_only_skip_unnecessary_work(self): + for changes, decisions, expected in ( + ((GO,), ("skip", "release", "release"), GO), + ((CLI,), ("skip", "skip", "release"), CLI), + ): + self.api = ControllerAPI() + self.controller = coordinator.Controller(self.api, "test-actor") + issue, state = self.approved(changes, decisions) + self.controller.advance(issue, state) + self.assertEqual(self.api.dispatched[0][0], expected) + + def test_no_change_cycle_finishes_without_dispatch(self): + issue, state = self.approved((), ("skip", "skip", "skip")) + self.assertEqual(state["status"], "completed") + self.controller.advance(issue, state) + self.assertEqual(self.api.dispatched, []) + + def test_deferred_producer_prevents_consumer_release(self): + with self.assertRaisesRegex(ValueError, "Resolve deferrals"): + self.approved((CORE,), ("defer", "release", "release")) + self.assertEqual(self.api.dispatched, []) + self.assertEqual(self.api.writes, []) + + def test_failed_worker_requires_explicit_retry_and_keeps_patch_version(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state, "ready", "failure") + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["nodes"][CORE]["status"], "failed") + self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), 1) + issue, state = self.controller.advance(issue, state, retry_failed=True) + self.assertEqual(state["nodes"][CORE]["attempt"], 2) + self.assertEqual(state["plan"]["snapshots"][CORE]["tag"], "v1.3.4-monthly-test.202610") + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, CORE]) + + def test_waiting_dependency_worker_is_resumed_not_replanned(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state, "waiting") + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["nodes"][CORE]["status"], "pending") + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["nodes"][CORE]["attempt"], 2) + self.assertEqual(len(self.api.dispatched), 2) + + def test_in_progress_worker_does_not_allow_consumers(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state, "verifying")["status"] = "in_progress" + self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), 1) + + def test_verified_completion_wakes_next_worker_before_callback_finishes(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state)["status"] = "in_progress" + with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): + self.controller.advance(issue, state) + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO]) + + def test_explicit_reassessment_refreshes_refs_without_another_patch(self): + issue, state = self.approved() + evidence = inventory((CORE,)) + evidence["repositories"][CORE]["pulls"][0]["head"] = "e" * 40 + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + issue, state = self.controller.approve(plan, 23, replan=True) + self.assertEqual(state["plan"]["snapshots"][CORE]["pulls"][0]["head"], "e" * 40) + self.assertEqual(state["plan"]["snapshots"][CORE]["tag"], "v1.3.4-monthly-test.202610") + self.assertEqual(state["revisions"][0]["source_run"], 23) + + def test_reassessment_cannot_modify_inflight_or_public_version(self): + issue, state = self.approved() + evidence = inventory((CORE,)) + evidence["repositories"][CORE]["pulls"][0]["head"] = "e" * 40 + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + self.controller.advance(issue, state) + with self.assertRaisesRegex(ValueError, "in-flight"): + self.controller.approve(plan, 23, replan=True) + state["nodes"][CORE]["status"] = "pending" + self.controller.save(issue, state) + with patch.object(self.api, "optional", return_value={"object": {"sha": MAIN}}), self.assertRaisesRegex(ValueError, "public candidate"): + self.controller.approve(plan, 23, replan=True) + + def verification_reassessment(self): + issue, state = self.approved() + self.api.worker(CORE, issue, state, "verifying", "failure") + state["nodes"][CORE]["status"] = "failed" + self.controller.save(issue, state) + evidence = inventory((GO,)) + source = evidence["repositories"][CORE] + source.update(release_head=MAIN, source_commit=MAIN, automation_sha="e" * 40) + self.api.changed[(CORE, source["branch"])] = MAIN + self.api.changed[(CORE, "main")] = "e" * 40 + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("skip", "release", "release"))) + return issue, state, plan + + def recover_verification(self, plan): + original = self.api.optional + def tag(path): + return {"object": {"sha": MAIN}} if path.startswith(f"repos/{CORE}/git/ref/tags/") else original(path) + with patch.object(self.api, "optional", side_effect=tag), \ + patch.object(release, "assert_tag") as signature, \ + patch.object(release, "check_public_assets") as assets: + result = self.controller.approve(plan, 23, replan=True, recover_public_verification=True) + signature.assert_called_once() + assets.assert_called_once() + return result + + def test_explicit_public_recovery_changes_only_worker_code_and_binds_asset_identity(self): + issue, previous, plan = self.verification_reassessment() + old_snapshot = copy.deepcopy(previous["plan"]["snapshots"][CORE]) + issue, state = self.recover_verification(plan) + snapshot = state["plan"]["snapshots"][CORE] + self.assertEqual({key: snapshot[key] for key in old_snapshot if key != "automation_sha"}, + {key: old_snapshot[key] for key in old_snapshot if key != "automation_sha"}) + self.assertEqual(snapshot["automation_sha"], "e" * 40) + self.assertEqual(snapshot["verification_recovery"]["from_plan_id"], previous["plan"]["plan_id"]) + self.assertEqual(state["plan"]["decisions"][CORE]["decision"], "release") + self.assertEqual(issue["number"], 1) + self.assertEqual(state["nodes"][CORE]["status"], "pending") + self.assertFalse(self.api.dispatched) + coordinator.validate_state(state) + + def test_verification_recovery_cannot_start_a_new_cycle_or_use_the_old_assessment(self): + evidence = inventory((CORE,)) + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + with self.assertRaisesRegex(ValueError, "existing approved cycle"): + self.controller.approve(plan, 22, replan=True, recover_public_verification=True) + self.controller.approve(plan, 22) + with self.assertRaisesRegex(ValueError, "fresh changed assessment"): + self.controller.approve(plan, 23, replan=True, recover_public_verification=True) + with self.assertRaisesRegex(ValueError, "explicit replan"): + self.controller.approve(plan, 23, recover_public_verification=True) + + def test_public_recovery_cannot_replace_tagged_but_unpublished_candidate(self): + issue, state, plan = self.verification_reassessment() + cycle = release.Cycle(self.api, CORE, MONTH, "rehearse") + cycle.state["status"] = "tagged" + cycle.save(cycle.state) + with self.assertRaisesRegex(ValueError, "exact public verification candidate"): + self.recover_verification(plan) + + def test_public_recovery_cannot_change_candidate_branch_or_baseline(self): + for field, value in (("release_head", BRANCH), ("baseline", "v1.4.0"), ("workflow_id", 90)): + with self.subTest(field=field): + self.api = ControllerAPI() + self.controller = coordinator.Controller(self.api, "test-actor") + _, _, plan = self.verification_reassessment() + plan["snapshots"][CORE][field] = value + plan["plan_id"] = coordinator.digest({key: value for key, value in plan.items() if key != "plan_id"}) + with self.assertRaisesRegex(ValueError, "cannot change the public candidate"): + self.recover_verification(plan) + + def test_public_recovery_refuses_bad_signature_assets_and_stale_automation(self): + for failure in ("signature", "assets", "stale"): + with self.subTest(failure=failure): + self.api = ControllerAPI() + self.controller = coordinator.Controller(self.api, "test-actor") + _, _, plan = self.verification_reassessment() + if failure == "stale": + self.api.changed[(CORE, "main")] = "f" * 40 + target = "assert_tag" if failure == "signature" else "check_public_assets" + original = self.api.optional + with patch.object(self.api, "optional", side_effect=lambda path: {"object": {"sha": MAIN}} if path.startswith(f"repos/{CORE}/git/ref/tags/") else original(path)), \ + patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"), \ + patch.object(release, target, side_effect=ValueError("Gate rejected")): + writes = len(self.api.writes) + with self.assertRaisesRegex(ValueError, "stale|Gate rejected"): + self.controller.approve(plan, 23, replan=True, recover_public_verification=True) + self.assertEqual(len(self.api.writes), writes) + + def test_public_recovery_rebinds_verification_without_git_or_new_publication(self): + _, _, plan = self.verification_reassessment() + issue, state = self.recover_verification(plan) + candidate = copy.deepcopy(release.Cycle(self.api, CORE, MONTH, "rehearse").state) + issue, state = self.controller.advance(issue, state) + environment = { + "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", "GITHUB_REF": "refs/heads/main", + "GITHUB_SHA": "e" * 40, "MONTHLY_PATCH_CONTROLLER_ISSUE": str(issue["number"]), + "MONTHLY_PATCH_CONTROLLER_PLAN": state["plan"]["plan_id"], "MONTHLY_PATCH_CONTROLLER_ATTEMPT": "1", + "MONTHLY_PATCH_REHEARSAL_ENABLED": "true", "MONTHLY_PATCH_TOKEN_READY": "true", + "MONTHLY_PATCH_SIGNING_KEY": "test-key", "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", + "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", + } + with patch.dict(os.environ, environment), patch.object(release, "assert_tag"), \ + patch.object(release, "check_public_assets"), patch.object(release, "git") as git: + result = release.prepare(self.api, CORE, "rehearse", MONTH, pathlib.Path("."), pathlib.Path("."), True) + self.assertEqual(result["action"], "verify") + self.assertEqual(result["controller_plan_id"], state["plan"]["plan_id"]) + for field in ("tag", "commit", "assets"): + self.assertEqual(result[field], candidate[field]) + git.assert_not_called() + for field, value in (("status", "ready"), ("commit", BRANCH), ("assets", [{"name": "unexpected"}])): + with self.subTest(field=field), self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, CORE, MONTH, {**result, field: value}) + coordinator.worker_authorization(self.api, CORE, MONTH, result) + with self.assertRaisesRegex(ValueError, "cannot build or republish"): + release.publish(self.api, result, pathlib.Path(".")) + cycle = release.Cycle(self.api, CORE, MONTH, "rehearse") + cycle.state["status"] = "published" + cycle.save(cycle.state) + coordinator.worker_authorization(self.api, CORE, MONTH, notification=True) + with self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, CORE, MONTH) + with self.assertRaisesRegex(ValueError, "cannot authorize writing"): + coordinator.worker_authorization(self.api, CORE, MONTH, result, notification=True) + with tempfile.TemporaryDirectory() as output, patch.object(release, "GitHub", return_value=self.api), \ + patch("sys.argv", ["monthly_release.py", "notify-controller", "--repository", CORE, + "--mode", "rehearse", "--month", MONTH, "--output", output]): + self.assertEqual(release.main(), 0) + hints = [payload for path, method, payload in self.api.writes + if path == f"repos/{CLI}/dispatches" and method == "POST"] + self.assertEqual(len(hints), 1) + self.assertEqual(hints[0]["event_type"], "notation-release-progress") + cycle.state["status"] = "verifying" + cycle.save(cycle.state) + with self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, CORE, MONTH, candidate) + cycle.state["status"] = "merging" + cycle.save(cycle.state) + with self.assertRaisesRegex(ValueError, "cannot change or rebuild"): + coordinator.worker_authorization(self.api, CORE, MONTH) + self.assertFalse(any(method == "PUT" for _, method, _ in self.api.writes)) + + def test_unacknowledged_dispatch_is_not_blindly_reissued(self): + issue, state = self.approved() + original = self.api.request + with patch.object(self.api, "request", wraps=self.api.request) as request: + def interrupted(path, method="GET", payload=None): + if path.endswith("/dispatches"): + raise release.APIError("HTTP 503 ambiguous request") + return original(path, method, payload) + request.side_effect = interrupted + with self.assertRaises(release.APIError): + self.controller.advance(issue, state) + issue, restored = self.controller.records()[0] + self.assertEqual(restored["nodes"][CORE]["status"], "dispatching") + self.controller.advance(issue, restored) + self.assertEqual(self.api.dispatched, []) + + def test_one_immutable_plan_per_month(self): + issue, state = self.approved() + same_issue, same_state = self.controller.approve(state["plan"], 22) + self.assertEqual(same_issue["number"], issue["number"]) + self.assertEqual(same_state, state) + evidence = inventory((CLI,)) + changed = coordinator.validate_assessment(evidence, assessment(evidence, ("skip", "skip", "release"))) + with self.assertRaisesRegex(ValueError, "immutable"): + self.controller.approve(changed, 23) + + def test_overlapping_months_cannot_be_approved(self): + self.approved() + evidence = inventory((CORE,)) + evidence["month"] = "2026-11" + for snapshot in evidence["repositories"].values(): + snapshot["tag"] = "v1.3.4-monthly-test.202611" + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + with self.assertRaisesRegex(ValueError, "active cycle"): + self.controller.approve(plan, 23) + + def test_stale_assessment_and_changed_refs_require_reassessment(self): + evidence = inventory((CORE,)) + evidence["collected_at"] = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=8)).isoformat() + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + with self.assertRaisesRegex(ValueError, "stale"): + self.controller.approve(plan, 22) + self.api.changed[(CORE, "monthly-patch-test-main")] = PR_HEAD + with self.assertRaisesRegex(ValueError, "refs changed"): + self.approved() + + def test_changed_worker_code_is_not_dispatched(self): + issue, state = self.approved() + self.api.changed[(CORE, "main")] = PR_HEAD + with self.assertRaisesRegex(ValueError, "code changed"): + self.controller.advance(issue, state) + self.assertEqual(self.api.dispatched, []) + + def test_untrusted_issues_cannot_authorize_or_block_controller(self): + issue, state = self.approved() + impostor = copy.deepcopy(issue) + impostor.update(number=99, user={"login": "attacker"}, body=issue["body"].replace(MONTH, "2026-11")) + self.api.issues_by_repo[CLI].append(impostor) + self.assertEqual(len(self.controller.records()), 1) + self.assertIsNone(coordinator.decode_issue(impostor, "test-actor")) + + def test_duplicate_or_mutated_trusted_records_block(self): + issue, state = self.approved() + duplicated = {**issue, "number": 99} + self.api.issues_by_repo[CLI].append(duplicated) + with self.assertRaisesRegex(ValueError, "Duplicate"): + self.controller.records() + state["plan"]["snapshots"][CORE]["tag"] = "v1.3.99-monthly-test.202610" + with self.assertRaisesRegex(ValueError, "plan changed"): + coordinator.validate_state(state) + + def test_wrong_worker_actor_code_or_title_does_not_complete(self): + for field, value in (("actor", {"login": "attacker"}), ("head_sha", PR_HEAD), ("display_title", "unrelated")): + self.api = ControllerAPI() + self.controller = coordinator.Controller(self.api, "test-actor") + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + run = self.api.worker(CORE, issue, state) + run[field] = value + if field == "display_title": + self.controller.advance(issue, state) + self.assertEqual(state["nodes"][CORE]["status"], "dispatching") + else: + with self.subTest(field=field), self.assertRaises(ValueError): + self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), 1) + + def test_missing_cycle_or_unverified_assets_cannot_release_consumer(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state) + self.api.issues_by_repo[CORE] = [] + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["nodes"][CORE]["status"], "failed") + self.assertEqual(len(self.api.dispatched), 1) + + def test_asset_validation_failure_blocks_consumer(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state) + with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets", side_effect=ValueError("bad asset digest")), self.assertRaisesRegex(ValueError, "digest"): + self.controller.advance(issue, state) + self.assertEqual(len(self.api.dispatched), 1) + + def test_published_consumer_must_include_the_exact_planned_library(self): + issue, state = self.approved() + issue, state = self.controller.advance(issue, state) + self.api.worker(CORE, issue, state) + with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): + issue, state = self.controller.advance(issue, state) + self.api.worker(GO, issue, state) + self.api.manifests[(GO, MAIN, ".")]["Replace"][0]["New"]["Version"] = "v1.3.0" + with self.assertRaisesRegex(ValueError, "planned producer"): + self.controller.advance(issue, state) + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO]) + + +class WorkerAuthorizationTests(unittest.TestCase): + def setUp(self): + self.api = ControllerAPI() + self.environment = patch.dict(os.environ, { + "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", + "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": AUTOMATION, + }) + self.environment.start() + self.addCleanup(self.environment.stop) + evidence = inventory((CORE,)) + plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) + self.controller = coordinator.Controller(self.api, "test-actor") + self.issue, self.state = self.controller.approve(plan, 22) + self.issue, self.state = self.controller.advance(self.issue, self.state) + os.environ.update({ + "MONTHLY_PATCH_CONTROLLER_ISSUE": str(self.issue["number"]), + "MONTHLY_PATCH_CONTROLLER_PLAN": plan["plan_id"], "MONTHLY_PATCH_CONTROLLER_ATTEMPT": "1", + }) + + def test_only_current_dispatched_node_is_authorized(self): + authorization = coordinator.worker_authorization(self.api, CORE, MONTH) + self.assertEqual(authorization["issue"], self.issue["number"]) + with self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, GO, MONTH) + with self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, "notaryproject/notation-core-go", MONTH) + + def test_propagation_reassessment_checkpoint_excludes_receipts_already_in_the_base(self): + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + plan["merged"] = [{"number": 6, "commit": BRANCH}, {"number": 3, "commit": MAIN}] + snapshot = {"snapshot": {"main_head": BRANCH}} + with patch.object(release, "Cycle") as cycle, patch.object(coordinator, "worker_authorization"), \ + patch.object(coordinator, "compare_commits", return_value={MAIN}): + cycle.return_value.state = plan + self.assertEqual(propagation.source_head(self.api, plan, snapshot), MAIN) + + def test_propagation_checkpoint_still_rejects_missing_or_unapproved_post_checkpoint_merges(self): + plan = worker_plan(GO, "rehearse", MONTH, "waiting") + plan["merged"] = [{"number": 6, "commit": BRANCH}, {"number": 3, "commit": MAIN}, + {"number": 4, "commit": PR_HEAD}] + for commits in ({MAIN}, {MAIN, PR_HEAD, AUTOMATION}): + with self.subTest(commits=commits), patch.object(release, "Cycle") as cycle, \ + patch.object(coordinator, "worker_authorization"), \ + patch.object(coordinator, "compare_commits", return_value=commits), \ + self.assertRaisesRegex(ValueError, "outside the approved"): + cycle.return_value.state = plan + propagation.source_head(self.api, plan, {"snapshot": {"main_head": BRANCH}}) + + def test_missing_stale_or_changed_receipt_is_rejected(self): + for variable, value in (("MONTHLY_PATCH_CONTROLLER_ISSUE", ""), ("MONTHLY_PATCH_CONTROLLER_PLAN", "0" * 64), + ("MONTHLY_PATCH_CONTROLLER_ATTEMPT", "2"), ("GITHUB_SHA", PR_HEAD), + ("GITHUB_REF", "refs/heads/other")): + with patch.dict(os.environ, {variable: value}), self.subTest(variable=variable), self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, CORE, MONTH) + + def test_candidate_must_keep_fixed_tag_mode_and_producers(self): + candidate = worker_plan(CORE, "rehearse", MONTH) + candidate.update(controller_issue=self.issue["number"], controller_plan_id=self.state["plan"]["plan_id"], producers={}) + coordinator.worker_authorization(self.api, CORE, MONTH, candidate) + for field, value in (("tag", "v1.3.99-monthly-test.202610"), ("mode", "execute"), + ("cycle_id", "upstream-" + "1" * 64), ("producers", {"notation-core-go": {}})): + with self.subTest(field=field), self.assertRaises(ValueError): + coordinator.worker_authorization(self.api, CORE, MONTH, {**candidate, field: value}) + + def test_producer_versions_are_taken_from_verified_plan_not_latest_unplanned_release(self): + self.state["nodes"][CORE].update(status="published", version="v1.3.4-monthly-test.202610", commit=MAIN) + self.state["nodes"][GO].update(status="dispatching", attempt=1) + self.issue = self.controller.save(self.issue, self.state) + authorization = coordinator.worker_authorization(self.api, GO, MONTH) + self.assertEqual(authorization["producers"], {"notation-core-go": {"repository": CORE, "version": "v1.3.4-monthly-test.202610"}}) + + def test_assessed_pr_heads_cannot_change(self): + authorization = coordinator.worker_authorization(self.api, CORE, MONTH) + candidate = pull(sha=PR_HEAD) + self.assertTrue(coordinator.scoped_pull(self.api, CORE, candidate, authorization)) + candidate["head"]["sha"] = MAIN + with self.assertRaisesRegex(ValueError, "head changed"): + coordinator.scoped_pull(self.api, CORE, candidate, authorization) + + def test_new_unplanned_third_party_update_is_not_added(self): + authorization = coordinator.worker_authorization(self.api, CORE, MONTH) + self.assertFalse(coordinator.scoped_pull(self.api, CORE, pull(number=99), authorization)) + + def test_new_producer_pr_must_only_update_planned_producers(self): + candidate = pull(number=99, sha=PR_HEAD) + candidate["head"]["repo"]["full_name"] = GO + candidate["base"]["sha"] = MAIN + base = {"Go": "1.26", "Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.0"}], + "Replace": [{"Old": {"Path": "github.com/notaryproject/notation-core-go"}, + "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.3-monthly-test.202609"}}]} + after = copy.deepcopy(base) + after["Replace"][0]["New"]["Version"] = "v1.3.4-monthly-test.202610" + self.api.manifests = {(GO, MAIN, "."): base, (GO, PR_HEAD, "."): after} + self.api.manifests[(CORE, "v1.3.4-monthly-test.202610", ".")] = {"Go": "1.26"} + producers = {"notation-core-go": {"repository": CORE, "version": "v1.3.4-monthly-test.202610"}} + self.assertTrue(coordinator.producer_pull(self.api, GO, candidate, producers)) + after["Require"].append({"Path": "unrelated/module", "Version": "v2.0.0"}) + self.assertFalse(coordinator.producer_pull(self.api, GO, candidate, producers)) + after["Require"].pop() + after["Go"] = "1.27" + self.assertFalse(coordinator.producer_pull(self.api, GO, candidate, producers)) + + def test_propagation_allows_transitive_requirements_and_required_go_floor(self): + candidate = pull(number=99, sha=PR_HEAD) + candidate["head"]["repo"]["full_name"] = GO + candidate["base"]["sha"] = MAIN + module = "github.com/notaryproject/notation-core-go" + base = {"Go": "1.26", "Require": [{"Path": module, "Version": "v1.3.0"}], + "Replace": [{"Old": {"Path": module}, "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.3-monthly-test.202609"}}]} + after = copy.deepcopy(base) + after["Go"] = "1.26.2" + after["Replace"][0]["New"]["Version"] = "v1.3.4-monthly-test.202610" + after["Require"].append({"Path": "transitive/module", "Version": "v1.2.3", "Indirect": True}) + self.api.manifests = {(GO, MAIN, "."): base, (GO, PR_HEAD, "."): after, + (CORE, "v1.3.4-monthly-test.202610", "."): {"Go": "1.26.2"}} + producers = {"notation-core-go": {"repository": CORE, "version": "v1.3.4-monthly-test.202610"}} + self.assertTrue(coordinator.producer_pull(self.api, GO, candidate, producers)) + after["Toolchain"] = "go1.27.1" + with patch.object(release, "run", return_value="go1.27.1\n"): + self.assertTrue(coordinator.producer_pull(self.api, GO, candidate, producers)) + after["Go"] = "1.27" + self.assertFalse(coordinator.producer_pull(self.api, GO, candidate, producers)) + + def test_merged_propagation_keeps_its_observed_provenance_after_base_moves(self): + authorization = coordinator.worker_authorization(self.api, CORE, MONTH) + candidate = pull(number=99) + candidate.update(state="closed", merged_at="2026-10-04T00:00:00Z", merge_commit_sha=BRANCH) + self.assertTrue(coordinator.scoped_pull(self.api, CORE, candidate, authorization, + [{"number": 99, "commit": BRANCH}])) + candidate["merge_commit_sha"] = MAIN + with self.assertRaisesRegex(ValueError, "merge changed"): + coordinator.scoped_pull(self.api, CORE, candidate, authorization, [{"number": 99, "commit": BRANCH}]) + + def test_cli_can_merge_separate_core_and_go_propagation_prs(self): + candidate = pull(number=99, sha=PR_HEAD) + candidate["head"]["repo"]["full_name"] = CLI + candidate["base"]["sha"] = MAIN + base = { + "Go": "1.26", "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} + for name in ("notation-core-go", "notation-go")], + "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, + "New": {"Path": f"github.com/yizha1/{name}", "Version": "v1.3.3-monthly-test.202609"}} + for name in ("notation-core-go", "notation-go")], + } + after = copy.deepcopy(base) + after["Replace"][0]["New"]["Version"] = "v1.3.4-monthly-test.202610" + producers = {name: {"repository": f"yizha1/{name}", "version": "v1.3.4-monthly-test.202610"} + for name in ("notation-core-go", "notation-go")} + self.api.manifests = {(item["repository"], item["version"], "."): {"Go": "1.26"} for item in producers.values()} + for directory in release.modules(CLI): + self.api.manifests[(CLI, MAIN, directory)] = base + self.api.manifests[(CLI, PR_HEAD, directory)] = after + self.assertTrue(coordinator.producer_pull(self.api, CLI, candidate, producers)) + + def test_worker_policy_includes_authorization_before_merging(self): + with patch.object(release, "policy"), patch.object(release, "resume_cycle") as resume, patch.dict( + os.environ, {"MONTHLY_PATCH_CONTROLLER_PLAN": "0" * 64} + ), self.assertRaises(ValueError): + release.prepare(self.api, CORE, "rehearse", MONTH, pathlib.Path("."), pathlib.Path(".")) + resume.assert_not_called() + + def test_workflows_do_not_schedule_or_publish_consumers_independently(self): + root = pathlib.Path(__file__).parent.parent + worker = (root / "workflows/monthly-patch-release.yml").read_text() + controller = (root / "workflows/notation-release-controller.yml").read_text() if (root / "workflows/notation-release-controller.yml").exists() else "" + agent = (root / "workflows/notation-release-agent.md").read_text() if (root / "workflows/notation-release-agent.md").exists() else "" + self.assertNotIn(" schedule:", worker) + self.assertNotIn(" push:", worker) + self.assertNotIn(" pull_request:", worker) + self.assertIn(" workflow_dispatch:", worker) + self.assertIn(" push:", (root / "workflows/notation-release-validation.yml").read_text()) + self.assertNotIn("options: [dry-run, rehearse, execute]", worker) + self.assertIn("MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'", worker) + self.assertIn("types: [notation-release-progress]", controller) if controller else None + if agent: + self.assertNotIn("MONTHLY_PATCH_SIGNING_KEY", agent) + self.assertNotIn("MONTHLY_PATCH_TOKEN", agent) + self.assertIn("report-failure-as-issue: false", agent) + + def test_manual_automation_files_keep_repository_license_headers(self): + root = pathlib.Path(__file__).parent.parent + header = "\n".join(pathlib.Path(release.__file__).read_text().splitlines()[:13]) + files = [root / "scripts/notation_release_controller.py", pathlib.Path(__file__), + root / "scripts/notation_fork_propagation.py", + root / "workflows/monthly-patch-release.yml", root / "workflows/notation-release-validation.yml"] + controller = root / "workflows/notation-release-controller.yml" + if controller.exists(): + files.append(controller) + for file in files: + with self.subTest(file=file): + self.assertTrue(file.read_text().startswith(header)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_upstream_authorization.py b/.github/scripts/test_upstream_authorization.py new file mode 100644 index 00000000..5ae78352 --- /dev/null +++ b/.github/scripts/test_upstream_authorization.py @@ -0,0 +1,380 @@ +# 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 unittest +from unittest.mock import patch + +import monthly_release as release +import notation_release_controller as controller +from test_monthly_release import FakeGitHub, body, plan as candidate, pull + +CORE, GO, CLI = ("notaryproject/" + name for name in release.PROJECTS) +SOURCE, BRANCH, PR_HEAD, MERGED = (character * 40 for character in "abcd") +MONTH = "2026-10" +POLICY = { + "schema": 1, "repositories": [CORE, GO, CLI], + "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, + "release_votes": {"notation-core-go": "branch-rules", "notation-go": "repo-maintainer-majority", + "notation": "repo-maintainer-majority"}, +} +TREE = [{"path": controller.WORKER_PATH, "sha": "e" * 40, "type": "blob", "mode": "100644"}] +AUTOMATION = controller.digest({item["path"]: {key: item[key] for key in ("sha", "mode", "type")} for item in TREE}) + + +class UpstreamAPI(FakeGitHub): + def __init__(self): + super().__init__() + self.issues_by_repo = {repo: [] for repo in (CORE, GO, CLI)} + self.changed = {} + self.tree = copy.deepcopy(TREE) + self.dispatched = [] + self.runs = {} + self.policy = copy.deepcopy(POLICY) + self.comparison = [] + self.prs = {} + self.editor = "test-actor" + + def issue_editor(self, repository, number): + return self.editor + + def request(self, path, method="GET", payload=None): + repository = "/".join(path.split("/")[1:3]) + suffix = "/".join(path.split("/")[3:]) + if path == "user": + return {"login": "test-actor"} + if method == "POST" and suffix.endswith("/dispatches"): + self.dispatched.append((repository, payload)) + self.writes.append((path, method, payload)) + return None + if method != "GET": + return super().request(path, method, payload) + + if not suffix: + return {"fork": False, "private": False, "default_branch": "main", "has_issues": True} + if suffix.startswith("branches/"): + name = suffix.removeprefix("branches/") + return {"commit": {"sha": self.changed.get((repository, name), SOURCE if name == "main" else BRANCH)}} + if suffix.startswith("git/trees/"): + return {"truncated": False, "tree": self.tree} + if suffix.startswith("git/ref/tags/"): + return {"object": {"type": "tag", "sha": "e" * 40}} + if suffix.startswith("git/tags/"): + return {"object": {"sha": BRANCH}, "verification": {"verified": True}} + if suffix.startswith(f"contents/{controller.POLICY_PATH}"): + return {"encoding": "base64", "content": base64.b64encode(json.dumps(self.policy).encode()).decode()} + if suffix.startswith("issues/"): + return next(item for item in self.issues_by_repo[repository] if item["number"] == int(suffix.rsplit("/", 1)[1])) + if suffix == "actions/workflows/monthly-patch-release.yml": + return {"id": (CORE, GO, CLI).index(repository) + 10, "state": "active"} + if "/runs?" in suffix: + return {"workflow_runs": list(self.runs.get(repository, {}).values())} + if suffix.startswith("actions/runs/"): + return self.runs[repository][int(suffix.rsplit("/", 1)[1])] + if suffix.startswith("compare/"): + return {"status": "ahead", "ahead_by": len(self.comparison), "commits": [{"sha": sha} for sha in self.comparison]} + if suffix.startswith("pulls/"): + return self.prs[(repository, int(suffix.rsplit("/", 1)[1]))] + return super().request(path, method, payload) + + def worker(self, repository, issue, state, status="published", conclusion="success"): + node = state["nodes"][repository] + run_id = len(self.runs) + 100 + run = { + "id": run_id, "actor": {"login": "test-actor"}, "head_sha": node["dispatch_sha"], + "workflow_id": (CORE, GO, CLI).index(repository) + 10, + "event": "workflow_dispatch", "status": "completed", "conclusion": conclusion, + "display_title": controller.run_title(MONTH, state["plan"]["plan_id"], issue["number"], node["attempt"]), + } + self.runs.setdefault(repository, {})[run_id] = run + producers = { + name: {"repository": f"notaryproject/{name}", "version": state["nodes"][f"notaryproject/{name}"]["version"]} + for name in release.PROJECTS[repository.split("/")[1]] + if state["nodes"][f"notaryproject/{name}"]["status"] == "published" + } + worker = candidate(repository, "execute", MONTH, status) + worker.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], producers=producers, assets=[]) + worker["maintainer_roster_sha"] = state["plan"]["snapshots"][repository]["automation_sha"] + release.Cycle(self, repository, MONTH, "execute").save(worker) + for directory in release.modules(repository): + self.manifests[(repository, worker["commit"], directory)] = { + "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": item["version"]} for name, item in producers.items()], + } + return run + + +def inventory(changes=(CORE,)): + return { + "schema": 1, "host": CLI, "month": MONTH, "collected_at": controller.utc_now(), + "repositories": { + repo: { + "baseline": "v1.3.0", "main": "main", "branch": "release-1.3", "tag": "v1.3.1", + "main_head": SOURCE, "release_head": BRANCH, "automation_sha": SOURCE, + "automation_digest": AUTOMATION, "workflow_id": index + 10, + "baseline_sha": BRANCH, "baseline_tag_object": "e" * 40, + "source_repository": repo, "source_commit": BRANCH, "blockers": [], + "pulls": [{"number": 1, "head": PR_HEAD}] if repo in changes else [], + "merged": [], "manifests": {directory: {} for directory in release.modules(repo)}, + "scans": {directory: {"reachable": [], "informational": [], "advisories": {}} for directory in release.modules(repo)}, + } + for index, repo in enumerate((CORE, GO, CLI)) + }, + } + + +def assessed(evidence, decisions=("release", "release", "release")): + assessment = { + "schema": 1, "month": MONTH, "inventory_sha256": controller.digest(evidence), + "decisions": { + repo: {"decision": decision, "reason": "Assessed update", + "evidence": [f"{repo}:dependencies"]} + for repo, decision in zip((CORE, GO, CLI), decisions) + }, + } + return controller.validate_assessment(evidence, assessment) + + +@unittest.skipIf(controller.FORK, "Canonical tests run in the canonical repository context") +class UpstreamAuthorizationTests(unittest.TestCase): + def setUp(self): + self.api = UpstreamAPI() + self.env = patch.dict(os.environ, { + "GITHUB_REPOSITORY": CLI, "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": SOURCE, + "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", + "NOTATION_RELEASE_AUTO_APPROVAL_ENABLED": "true", + }) + self.env.start() + self.addCleanup(self.env.stop) + self.controller = controller.Controller(self.api, "test-actor") + + def dispatch(self): + issue, state = self.controller.approve(assessed(inventory()), 22) + issue, state = self.controller.advance(issue, state) + os.environ.update({ + "MONTHLY_PATCH_CONTROLLER_ISSUE": str(issue["number"]), + "MONTHLY_PATCH_CONTROLLER_PLAN": state["plan"]["plan_id"], + "MONTHLY_PATCH_CONTROLLER_ATTEMPT": "1", + }) + return issue, state + + def test_canonical_plan_and_dispatch_use_stable_versions_and_real_main(self): + issue, state = self.dispatch() + self.assertEqual(controller.HOST, CLI) + self.assertEqual(state["mode"], "execute") + self.assertEqual(self.api.dispatched[0][0], CORE) + self.assertEqual(self.api.dispatched[0][1]["inputs"]["mode"], "execute") + self.assertEqual(controller.worker_authorization(self.api, CORE, MONTH)["snapshot"]["tag"], "v1.3.1") + with self.assertRaises(ValueError): + controller.worker_authorization(self.api, "yizha1/notation-core-go", MONTH) + + def test_changed_automation_blocks_dispatch_and_worker(self): + issue, state = self.dispatch() + self.api.tree[0]["sha"] = "f" * 40 + with self.assertRaisesRegex(ValueError, "code changed"): + controller.worker_authorization(self.api, CORE, MONTH) + state["nodes"][CORE]["status"] = "pending" + with self.assertRaisesRegex(ValueError, "code changed"): + self.controller.advance(issue, state) + + def test_recorded_dependency_merge_does_not_invalidate_automation(self): + issue, state = self.dispatch() + worker = candidate(CORE, "execute", MONTH, "waiting") + worker.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], + merged=[{"number": 1, "commit": MERGED}], producers={}) + release.Cycle(self.api, CORE, MONTH, "execute").save(worker) + self.api.changed[(CORE, "main")] = MERGED + self.api.comparison = [MERGED] + merged = pull(sha=PR_HEAD) + merged.update(merged_at="2026-10-01T00:00:00Z", merge_commit_sha=MERGED, state="closed") + self.api.prs[(CORE, 1)] = merged + self.assertEqual(controller.source_checkpoint(self.api, CORE, state["plan"]["snapshots"][CORE], MONTH), MERGED) + controller.worker_authorization(self.api, CORE, MONTH) + self.api.comparison.append("f" * 40) + with self.assertRaisesRegex(ValueError, "outside recorded"): + controller.worker_authorization(self.api, CORE, MONTH) + + def test_unrecorded_main_changes_require_reassessment(self): + issue, state = self.dispatch() + self.api.changed[(CORE, "main")] = MERGED + with self.assertRaisesRegex(ValueError, "trusted worker"): + controller.worker_authorization(self.api, CORE, MONTH) + + def test_policy_is_disabled_by_default_and_exactly_scoped(self): + with patch.dict(os.environ, {"NOTATION_RELEASE_AUTO_APPROVAL_ENABLED": ""}), self.assertRaisesRegex(ValueError, "disabled"): + controller.authorize_routine(self.api, assessed(inventory())) + self.api.policy["optional_backports"] = "automatic" + with self.assertRaisesRegex(ValueError, "Unsupported"): + controller.authorize_routine(self.api, assessed(inventory())) + + def test_automatic_authorization_checks_semantics_not_agent_rationale(self): + update = pull(sha=PR_HEAD) + update["base"]["sha"] = SOURCE + self.api.prs[(CORE, 1)] = update + before = {"Go": "1.24", "Require": [{"Path": "example/dependency", "Version": "v1.2.0"}]} + after = copy.deepcopy(before) + after["Require"][0]["Version"] = "v1.2.1" + self.api.manifests[(CORE, SOURCE, ".")] = before + self.api.manifests[(CORE, PR_HEAD, ".")] = after + receipt = controller.authorize_routine(self.api, assessed(inventory())) + self.assertEqual(receipt["kind"], "policy") + for version in ("v2.0.0", "v1.3.0", "v1.1.9", "v1.2.1-rc.1"): + after["Require"][0]["Version"] = version + with self.subTest(version=version), self.assertRaises(ValueError): + controller.authorize_routine(self.api, assessed(inventory())) + + def test_reachable_findings_and_workflow_changes_need_explicit_resolution(self): + evidence = inventory() + evidence["repositories"][CORE]["scans"]["."]["reachable"] = ["GO-2026-1234"] + with self.assertRaisesRegex(ValueError, "Reachable"): + controller.authorize_routine(self.api, assessed(evidence)) + update = pull(sha=PR_HEAD) + update["base"]["sha"] = SOURCE + self.api.prs[(CORE, 1)] = update + self.api.files = [{"filename": ".github/workflows/build.yml"}] + with self.assertRaisesRegex(ValueError, "explicit actor"): + controller.authorize_routine(self.api, assessed(inventory())) + + def test_exceptional_authorization_requires_manual_configured_actor(self): + for event, actor in (("schedule", "test-actor"), ("workflow_dispatch", "attacker")): + with patch.dict(os.environ, {"GITHUB_EVENT_NAME": event, "GITHUB_ACTOR": actor}), self.assertRaises(ValueError): + controller.explicit_actor() + with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_ACTOR": "test-actor"}): + controller.explicit_actor() + + def test_late_producer_updates_require_exact_canonical_versions(self): + update = pull(number=99, sha=PR_HEAD) + update["head"]["repo"]["full_name"] = GO + update["base"]["sha"] = SOURCE + before = {"Go": "1.24", "Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.0"}]} + after = copy.deepcopy(before) + after["Require"][0]["Version"] = "v1.3.1" + self.api.manifests = { + (GO, SOURCE, "."): before, (GO, PR_HEAD, "."): after, + (CORE, "v1.3.1", "."): {"Go": "1.24"}, + } + producers = {"notation-core-go": {"repository": CORE, "version": "v1.3.1"}} + with patch.object(release, "run", return_value="go1.27.1"): + self.assertTrue(controller.producer_pull(self.api, GO, update, producers)) + after["Require"][0]["Version"] = "v1.3.2" + self.assertFalse(controller.producer_pull(self.api, GO, update, producers)) + after["Require"][0]["Version"] = "v1.3.1" + after["Replace"] = [{"Old": {"Path": "github.com/notaryproject/notation-core-go"}, + "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.1"}}] + with self.assertRaises(ValueError): + controller.producer_pull(self.api, GO, update, producers) + + def test_source_and_packaging_jobs_receive_no_release_secrets(self): + workflow = (pathlib.Path(__file__).parent.parent / "workflows/monthly-patch-release.yml").read_text() + for name, next_name in (("qualify", "package"), ("package", "publish"), ("verify", "complete")): + section = workflow.split(f" {name}:\n", 1)[1].split(f" {next_name}:\n", 1)[0] + self.assertNotIn("secrets.", section) + publisher = workflow.split(" publish:\n", 1)[1].split(" verify:\n", 1)[0] + self.assertNotIn("build_monthly_assets.py", publisher) + self.assertNotIn("go test", publisher) + callback = workflow.split(" notify-controller:\n", 1)[1] + self.assertIn("inputs.mode == 'execute'", callback) + self.assertIn("github.actor == vars.MONTHLY_PATCH_ACTOR", callback) + self.assertIn("github.ref == 'refs/heads/main'", callback) + + def test_worker_roster_pin_must_match_assessed_main(self): + issue, state = self.dispatch() + worker = candidate(CORE, "execute", MONTH, "waiting") + worker.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], producers={}) + with self.assertRaisesRegex(ValueError, "roster"): + controller.worker_authorization(self.api, CORE, MONTH, worker) + worker["maintainer_roster_sha"] = SOURCE + controller.worker_authorization(self.api, CORE, MONTH, worker) + worker["maintainer_roster_sha"] = BRANCH + with self.assertRaisesRegex(ValueError, "roster"): + controller.worker_authorization(self.api, CORE, MONTH, worker) + + def test_public_recovery_preserves_distinct_voted_commit_and_merge_tip(self): + issue, previous = self.controller.approve(assessed(inventory()), 22) + worker = candidate(CORE, "execute", MONTH, "verifying") + worker.update(commit=PR_HEAD, previous_head=MERGED, release_backport={"number": 7}, + controller_issue=issue["number"], controller_plan_id=previous["plan"]["plan_id"], assets=[]) + release.Cycle(self.api, CORE, MONTH, "execute").save(worker) + evidence = inventory() + evidence["repositories"][CORE].update(main_head="f" * 40, automation_sha="f" * 40, release_head=MERGED) + self.api.changed[(CORE, "main")] = "f" * 40 + self.api.changed[(CORE, "release-1.3")] = MERGED + replacement = assessed(evidence) + optional = self.api.optional + self.api.changed[(CORE, "release-1.3")] = SOURCE + with patch.object(self.api, "optional", side_effect=lambda path: {"object": {"sha": PR_HEAD}} if path == f"repos/{CORE}/git/ref/tags/v1.3.1" else optional(path)), \ + patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"), \ + self.assertRaisesRegex(ValueError, "stale"): + self.controller.approve(replacement, 23, replan=True, recover_public_verification=True) + self.api.changed[(CORE, "release-1.3")] = MERGED + with patch.object(self.api, "optional", side_effect=lambda path: {"object": {"sha": PR_HEAD}} if path == f"repos/{CORE}/git/ref/tags/v1.3.1" else optional(path)), \ + patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): + issue, state = self.controller.approve(replacement, 23, replan=True, recover_public_verification=True) + self.assertEqual(state["plan"]["snapshots"][CORE]["main_head"], "f" * 40) + self.assertEqual(release.Cycle(self.api, CORE, MONTH, "execute").state["commit"], PR_HEAD) + self.assertEqual(release.Cycle(self.api, CORE, MONTH, "execute").state["previous_head"], MERGED) + def test_canonical_core_go_cli_order_and_verified_producer_authority(self): + issue, state = self.dispatch() + core_run = self.api.worker(CORE, issue, state) + with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): + issue, state = self.controller.advance(issue, state) + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO]) + authorization = controller.worker_authorization(self.api, GO, MONTH) + self.assertEqual(authorization["producers"], {"notation-core-go": {"repository": CORE, "version": "v1.3.1"}}) + core_run["actor"]["login"] = "forged" + with self.assertRaisesRegex(ValueError, "trusted successful"): + controller.worker_authorization(self.api, GO, MONTH) + core_run["actor"]["login"] = "test-actor" + self.api.worker(GO, issue, state) + issue, state = self.controller.advance(issue, state) + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO, CLI]) + self.api.worker(CLI, issue, state) + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["status"], "completed") + + def test_verification_record_alone_cannot_advance_before_worker_run_success(self): + issue, state = self.dispatch() + run = self.api.worker(CORE, issue, state) + run["status"] = "in_progress" + issue, state = self.controller.advance(issue, state) + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE]) + run.update(status="completed", conclusion="failure") + issue, state = self.controller.advance(issue, state) + self.assertEqual(state["nodes"][CORE]["status"], "failed") + self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE]) + + def test_signed_baseline_and_coordinator_receipt_cannot_be_bypassed(self): + self.dispatch() + with patch.dict(os.environ, {"MONTHLY_PATCH_COORDINATOR_REQUIRED": ""}), self.assertRaisesRegex(ValueError, "always require"): + controller.worker_authorization(self.api, CORE, MONTH) + with patch.object(release, "verified_tag", return_value="f" * 40), self.assertRaisesRegex(ValueError, "baseline"): + controller.worker_authorization(self.api, CORE, MONTH) + + def test_issue_author_alone_cannot_authorize_a_forged_state_edit(self): + self.dispatch() + self.api.editor = "attacker" + with self.assertRaisesRegex(ValueError, "edited outside"): + controller.worker_authorization(self.api, CORE, MONTH) + with self.assertRaisesRegex(ValueError, "edited outside"): + self.controller.records() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/monthly-patch-release.yml b/.github/workflows/monthly-patch-release.yml new file mode 100644 index 00000000..3fccd7c6 --- /dev/null +++ b/.github/workflows/monthly-patch-release.yml @@ -0,0 +1,513 @@ +# 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. + +name: Monthly dependency patch release +run-name: 'Monthly patch ${{ inputs.month }} ${{ inputs.plan_id }} #${{ inputs.controller_issue }} attempt ${{ inputs.attempt }}' + +on: + workflow_dispatch: + inputs: + mode: + description: No-write plan, or coordinator-authorized canonical execution + type: choice + options: [dry-run, execute] + default: dry-run + required: true + month: + description: Optional YYYY-MM cycle to inspect or resume (UTC month by default) + type: string + required: false + controller_issue: + description: Trusted cycle issue in notaryproject/notation, required for execution + type: string + required: false + plan_id: + description: Approved assessment digest, required for execution + type: string + required: false + attempt: + description: Dispatched worker attempt, required for execution + type: string + required: false + +permissions: + contents: read + issues: read + pull-requests: read + +concurrency: + group: monthly-dependency-patch-${{ github.repository }} + cancel-in-progress: false + +env: + GOWORK: 'off' + GOTOOLCHAIN: local + MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true' + MONTHLY_PATCH_CONTROLLER_ISSUE: ${{ inputs.controller_issue }} + MONTHLY_PATCH_CONTROLLER_PLAN: ${{ inputs.plan_id }} + MONTHLY_PATCH_CONTROLLER_ATTEMPT: ${{ inputs.attempt }} + +jobs: + prepare: + if: >- + contains(fromJSON('["notaryproject/notation-core-go","notaryproject/notation-go","notaryproject/notation"]'), github.repository) && + github.ref == 'refs/heads/main' && + github.event_name == 'workflow_dispatch' && + (inputs.mode == 'dry-run' || github.actor == vars.MONTHLY_PATCH_ACTOR) + runs-on: ubuntu-24.04 + outputs: + action: ${{ steps.cycle.outputs.action }} + commit: ${{ steps.cycle.outputs.commit }} + tag: ${{ steps.cycle.outputs.tag }} + baseline: ${{ steps.cycle.outputs.baseline }} + minimum_go: ${{ steps.cycle.outputs.minimum_go }} + mode: ${{ steps.cycle.outputs.mode }} + month: ${{ steps.cycle.outputs.month }} + cycle_id: ${{ steps.cycle.outputs.cycle_id }} + status: ${{ steps.cycle.outputs.status }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: stable + check-latest: true + - name: Test orchestration before using repository credentials + env: + MONTHLY_PATCH_COORDINATOR_REQUIRED: 'false' + run: | + GITHUB_REPOSITORY=yizha1/notation python3 -m unittest discover -s .github/scripts -p 'test_*release*.py' + python3 -m unittest discover -s .github/scripts -p 'test_upstream_authorization.py' + - name: Merge checked Dependabot PRs and prepare an unpushed release candidate + id: cycle + env: + GH_TOKEN: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN || github.token }} + MONTHLY_PATCH_TOKEN_READY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN != '' }} + MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} + MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} + MONTHLY_PATCH_SIGNING_KEY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY || '' }} + MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} + MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} + MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + MODE: ${{ inputs.mode }} + REQUESTED_MONTH: ${{ inputs.month }} + run: | + args=(--start) + if [[ -n "$REQUESTED_MONTH" ]]; then args+=(--month "$REQUESTED_MONTH"); fi + python3 .github/scripts/monthly_release.py prepare \ + --mode "$MODE" --output "$RUNNER_TEMP/monthly-candidate" "${args[@]}" + - name: Retain cycle plan and candidate bundle + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-candidate + path: | + ${{ runner.temp }}/monthly-candidate/plan.json + ${{ runner.temp }}/monthly-candidate/candidate.bundle + if-no-files-found: warn + + qualify: + needs: prepare + if: needs.prepare.outputs.action == 'qualify' + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + go-version: ['${{ needs.prepare.outputs.minimum_go }}', stable] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: automation + persist-credentials: false + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: candidate + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: monthly-candidate + path: ${{ runner.temp }}/monthly-candidate + - name: Import the exact unpushed candidate + run: | + python3 automation/.github/scripts/monthly_release.py import \ + --directory candidate --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ + --bundle "$RUNNER_TEMP/monthly-candidate/candidate.bundle" \ + --output "$RUNNER_TEMP/imported" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + cache-dependency-path: 'candidate/**/go.sum' + - name: Install pinned qualification tools + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + go install github.com/apache/skywalking-eyes/cmd/license-eye@v0.9.0 + - name: Qualify modules, tests, licensing and vulnerabilities + env: + MONTHLY_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + MONTHLY_PATCH_ADVISORY_DISPOSITIONS: ${{ vars.MONTHLY_PATCH_ADVISORY_DISPOSITIONS }} + run: | + python3 automation/.github/scripts/qualify_monthly_candidate.py candidate \ + --repository "$GITHUB_REPOSITORY" --evidence "$RUNNER_TEMP/source-evidence" + - name: Retain race coverage, tests and scan results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-source-${{ matrix.go-version }} + path: ${{ runner.temp }}/source-evidence/ + if-no-files-found: warn + + package: + needs: [prepare, qualify] + if: >- + always() && needs.prepare.result == 'success' && + ((needs.prepare.outputs.action == 'qualify' && needs.qualify.result == 'success') || + needs.prepare.outputs.action == 'package') + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: automation + persist-credentials: false + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: candidate + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: monthly-candidate + path: ${{ runner.temp }}/monthly-candidate + - name: Import or check out the exact qualified commit + env: + ACTION: ${{ needs.prepare.outputs.action }} + COMMIT: ${{ needs.prepare.outputs.commit }} + run: | + if [[ "$ACTION" == qualify ]]; then + python3 automation/.github/scripts/monthly_release.py import \ + --directory candidate --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ + --bundle "$RUNNER_TEMP/monthly-candidate/candidate.bundle" \ + --output "$RUNNER_TEMP/imported" + else + git -C candidate checkout --detach "$COMMIT" + fi + - name: Stage an unpushed local tag for packaging without any signing credentials + if: needs.prepare.outputs.action == 'qualify' + env: + TAG: ${{ needs.prepare.outputs.tag }} + COMMIT: ${{ needs.prepare.outputs.commit }} + run: | + git -C candidate -c user.name=Packaging -c user.email=packaging@example.invalid \ + -c tag.gpgsign=false tag -a "$TAG" "$COMMIT" -m "Unpublished packaging tag" + - name: Fetch the immutable tag without overwriting it + if: needs.prepare.outputs.action == 'package' + env: + TAG: ${{ needs.prepare.outputs.tag }} + run: | + git -C candidate fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: stable + check-latest: true + - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + if: github.event.repository.name == 'notation' + env: + GORELEASER_PREVIOUS_TAG: ${{ needs.prepare.outputs.baseline }} + with: + workdir: candidate + distribution: goreleaser + version: v2.18.2 + args: release --clean --skip=publish --config ../automation/.github/monthly-goreleaser.yml + - name: Build library source archives or validate every CLI archive + env: + TAG: ${{ needs.prepare.outputs.tag }} + COMMIT: ${{ needs.prepare.outputs.commit }} + MONTHLY_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + MONTHLY_PATCH_ADVISORY_DISPOSITIONS: ${{ vars.MONTHLY_PATCH_ADVISORY_DISPOSITIONS }} + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + python3 automation/.github/scripts/build_monthly_assets.py candidate \ + --repository "$GITHUB_REPOSITORY" --tag "$TAG" --commit "$COMMIT" \ + --evidence "$RUNNER_TEMP/build-evidence" + - name: Retain qualified assets without publishing + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-built-assets + path: candidate/dist/* + if-no-files-found: error + - name: Retain binary gate results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-build-evidence + path: ${{ runner.temp }}/build-evidence/ + if-no-files-found: warn + + publish: + needs: [prepare, qualify, package] + if: >- + always() && needs.prepare.result == 'success' && needs.package.result == 'success' && + github.ref == 'refs/heads/main' && github.actor == vars.MONTHLY_PATCH_ACTOR && + ((needs.prepare.outputs.action == 'qualify' && needs.qualify.result == 'success') || + needs.prepare.outputs.action == 'package') + runs-on: ubuntu-24.04 + steps: + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: stable + check-latest: true + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + path: automation + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + path: candidate + fetch-depth: 0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: monthly-candidate + path: ${{ runner.temp }}/monthly-candidate + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: monthly-built-assets + path: ${{ runner.temp }}/monthly-assets + - name: Import immutable candidate without running source or build commands + env: + ACTION: ${{ needs.prepare.outputs.action }} + COMMIT: ${{ needs.prepare.outputs.commit }} + run: | + if [[ "$ACTION" == qualify ]]; then + python3 automation/.github/scripts/monthly_release.py import --directory candidate \ + --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ + --bundle "$RUNNER_TEMP/monthly-candidate/candidate.bundle" \ + --output "$RUNNER_TEMP/imported" + else + git -C candidate checkout --detach "$COMMIT" + fi + - name: Stage the exact signed tag in the isolated publisher + if: needs.prepare.outputs.action == 'qualify' + env: + GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} + MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} + MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} + MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} + MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} + MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} + MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + run: | + python3 automation/.github/scripts/monthly_release.py stage-tag --directory candidate \ + --plan "$RUNNER_TEMP/monthly-candidate/plan.json" --output "$RUNNER_TEMP/staged" + - name: Atomically publish the branch and signed tag after every asset gate + if: needs.prepare.outputs.action == 'qualify' + env: + GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} + MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} + MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} + MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} + MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} + MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} + MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} + MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + run: | + python3 automation/.github/scripts/monthly_release.py tag --directory candidate \ + --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ + --output "$RUNNER_TEMP/tagged" + - name: Publish only qualified assets + id: release + env: + GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} + MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} + MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} + MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} + MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} + MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} + MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} + MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + ACTION: ${{ needs.prepare.outputs.action }} + run: | + plan="$RUNNER_TEMP/monthly-candidate/plan.json" + if [[ "$ACTION" == qualify ]]; then plan="$RUNNER_TEMP/tagged/plan.json"; fi + python3 automation/.github/scripts/monthly_release.py publish \ + --directory "$RUNNER_TEMP/monthly-assets" --plan "$plan" --output "$RUNNER_TEMP/published" + - name: Retain publication manifest and binary gate results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-publication + path: | + ${{ runner.temp }}/published/ + if-no-files-found: warn + + verify: + needs: [prepare, publish] + if: >- + always() && needs.prepare.result == 'success' && + (needs.publish.result == 'success' || needs.prepare.outputs.action == 'verify') + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + platform: linux_amd64 + - os: macos-14 + platform: darwin_arm64 + - os: windows-latest + platform: windows_amd64 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: automation + persist-credentials: false + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: candidate + ref: ${{ needs.prepare.outputs.commit }} + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: needs.publish.result == 'success' + with: + name: monthly-publication + path: ${{ runner.temp }}/publication + - name: Locate publication manifest + if: needs.publish.result == 'success' + shell: bash + run: cp "$RUNNER_TEMP/publication/published/plan.json" "$RUNNER_TEMP/publication/plan.json" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: needs.prepare.outputs.action == 'verify' + with: + name: monthly-candidate + path: ${{ runner.temp }}/publication + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: stable + check-latest: true + - name: Test the downloaded public release, never a rebuilt CLI + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PLATFORM: ${{ matrix.platform }} + MONTHLY_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + MONTHLY_PATCH_ADVISORY_DISPOSITIONS: ${{ vars.MONTHLY_PATCH_ADVISORY_DISPOSITIONS }} + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + python3 automation/.github/scripts/test_monthly_public_release.py \ + --plan "$RUNNER_TEMP/publication/plan.json" --source candidate \ + --platform "$PLATFORM" --evidence "$RUNNER_TEMP/public-evidence" + - name: Retain native functional tests and packaged-binary E2E evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-public-${{ matrix.platform }} + path: ${{ runner.temp }}/public-evidence/ + if-no-files-found: warn + + complete: + needs: [prepare, publish, verify] + if: >- + always() && needs.verify.result == 'success' && + (needs.publish.result == 'success' || needs.prepare.outputs.action == 'verify') + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: needs.publish.result == 'success' + with: + name: monthly-publication + path: ${{ runner.temp }}/publication + - name: Locate publication manifest + if: needs.publish.result == 'success' + run: cp "$RUNNER_TEMP/publication/published/plan.json" "$RUNNER_TEMP/publication/plan.json" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: needs.prepare.outputs.action == 'verify' + with: + name: monthly-candidate + path: ${{ runner.temp }}/publication + - name: Complete the producer cycle only after public package verification + env: + GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} + MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} + MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} + MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} + MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} + MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} + MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + run: | + python3 .github/scripts/monthly_release.py complete \ + --plan "$RUNNER_TEMP/publication/plan.json" --output "$RUNNER_TEMP/completed" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: monthly-completed + path: ${{ runner.temp }}/completed/plan.json + if-no-files-found: error + + record-failure: + needs: [prepare, qualify, package, publish, verify, complete] + if: >- + always() && contains(needs.*.result, 'failure') && + needs.prepare.outputs.mode != '' && needs.prepare.outputs.mode != 'dry-run' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Keep consumers blocked and record the failure for the next retry + env: + GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} + MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} + MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} + MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} + MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} + MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} + MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + MODE: ${{ needs.prepare.outputs.mode }} + MONTH: ${{ needs.prepare.outputs.month }} + CYCLE_ID: ${{ needs.prepare.outputs.cycle_id }} + FAILURE_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python3 .github/scripts/monthly_release.py failure \ + --mode "$MODE" --month "$MONTH" --cycle-id "$CYCLE_ID" --output "$RUNNER_TEMP/failure" \ + --reason "A workflow gate failed. Inspect $FAILURE_URL and its retained evidence before retrying." + + notify-controller: + needs: [prepare, qualify, package, publish, verify, complete, record-failure] + if: >- + always() && needs.prepare.result == 'success' && needs.prepare.outputs.mode == 'execute' && + inputs.mode == 'execute' && github.ref == 'refs/heads/main' && + github.actor == vars.MONTHLY_PATCH_ACTOR + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Wake the controller, never publish a consumer directly + env: + GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} + MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} + MONTH: ${{ needs.prepare.outputs.month }} + run: | + python3 .github/scripts/monthly_release.py notify-controller \ + --mode execute --month "$MONTH" --output "$RUNNER_TEMP/notified" diff --git a/.github/workflows/notation-release-validation.yml b/.github/workflows/notation-release-validation.yml new file mode 100644 index 00000000..507942d9 --- /dev/null +++ b/.github/workflows/notation-release-validation.yml @@ -0,0 +1,95 @@ +# 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. + +name: Notation release automation checks + +on: + push: + branches: [main] + paths: + - '.github/scripts/**' + - '.github/workflows/monthly-patch-release.yml' + - '.github/monthly-patch-policy.yml' + - '.github/workflows/notation-release-*' + - '.github/licenserc.yml' + - '.gitattributes' + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/monthly-patch-release.yml' + - '.github/monthly-patch-policy.yml' + - '.github/workflows/notation-release-*' + - '.github/licenserc.yml' + - '.gitattributes' + workflow_dispatch: + +permissions: + contents: read + actions: read + issues: read + pull-requests: read + +jobs: + validate: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + GOWORK: 'off' + GOTOOLCHAIN: local + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: stable + check-latest: true + - name: Test release controller and worker regressions + run: | + GITHUB_REPOSITORY=yizha1/notation python3 -m unittest discover -s .github/scripts -p 'test_*release*.py' + python3 -m unittest discover -s .github/scripts -p 'test_upstream_authorization.py' + - name: Lint the worker and validation workflow + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + actionlint .github/workflows/monthly-patch-release.yml \ + .github/workflows/notation-release-validation.yml + - name: Validate the compiled assessment workflow when present + env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ -f .github/workflows/notation-release-agent.md ]]; then + gh extension install github/gh-aw --pin v0.89.21 + gh aw compile notation-release-agent --strict --no-emit \ + --gh-aw-ref c35393777e5604a63721d09512263b1383301d4f --no-check-update + actionlint .github/workflows/notation-release-controller.yml + # actionlint does not yet recognize gh-aw's generated job-level queue. + actionlint -ignore '^unexpected key "queue" for "concurrency" section\.' \ + .github/workflows/notation-release-agent.lock.yml + gh aw list --json | jq -e 'all(.[]; .compiled == "Yes")' + fi + - name: Collect canonical evidence without AI or release credentials + if: github.repository == 'notaryproject/notation' && github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + python3 .github/scripts/notation_release_controller.py collect \ + --output "$RUNNER_TEMP/notation-release-evidence" + - name: Retain assessment evidence, including explicit setup blockers + if: always() && github.repository == 'notaryproject/notation' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: notation-release-evidence + path: ${{ runner.temp }}/notation-release-evidence/ + if-no-files-found: warn From d16c0df8b902d64407f843d0c29102542437059c Mon Sep 17 00:00:00 2001 From: Yi Zha <107919912+yizha1@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:45:14 -0700 Subject: [PATCH 2/5] refactor(release): use centralized monthly patch worker Replace copied release implementation and tests with a thin full-SHA reusable workflow caller. Preserve Go release voting and disabled setup, and require rebinding the provisional CLI pin to the final upstream implementation before merge or activation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc64f14-49a5-4a09-8b61-fd3ee5500435 Signed-off-by: Yi Zha <107919912+yizha1@users.noreply.github.com> --- .gitattributes | 1 - .github/MONTHLY_PATCH.md | 28 +- .github/scripts/build_monthly_assets.py | 66 - .github/scripts/check_notation_fork_setup.py | 135 -- .github/scripts/monthly_cli_smoke.py | 115 -- .github/scripts/monthly_release.py | 1150 ---------------- .github/scripts/monthly_release_checks.py | 281 ---- .github/scripts/monthly_release_pr.py | 256 ---- .github/scripts/monthly_release_vote.py | 302 ----- .github/scripts/notation_fork_propagation.py | 200 --- .../scripts/notation_release_controller.py | 1184 ----------------- .github/scripts/qualify_monthly_candidate.py | 82 -- .../scripts/test_monthly_public_release.py | 86 -- .github/scripts/test_monthly_release.py | 707 ---------- .../scripts/test_monthly_release_checks.py | 234 ---- .github/scripts/test_monthly_release_pr.py | 245 ---- .github/scripts/test_monthly_release_vote.py | 390 ------ .github/scripts/test_notation_fork_release.py | 490 ------- .../test_notation_release_controller.py | 826 ------------ .../scripts/test_upstream_authorization.py | 380 ------ .github/workflows/monthly-patch-release.yml | 483 +------ .../workflows/notation-release-validation.yml | 95 -- 22 files changed, 36 insertions(+), 7700 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .github/scripts/build_monthly_assets.py delete mode 100644 .github/scripts/check_notation_fork_setup.py delete mode 100644 .github/scripts/monthly_cli_smoke.py delete mode 100644 .github/scripts/monthly_release.py delete mode 100644 .github/scripts/monthly_release_checks.py delete mode 100644 .github/scripts/monthly_release_pr.py delete mode 100644 .github/scripts/monthly_release_vote.py delete mode 100644 .github/scripts/notation_fork_propagation.py delete mode 100644 .github/scripts/notation_release_controller.py delete mode 100644 .github/scripts/qualify_monthly_candidate.py delete mode 100644 .github/scripts/test_monthly_public_release.py delete mode 100644 .github/scripts/test_monthly_release.py delete mode 100644 .github/scripts/test_monthly_release_checks.py delete mode 100644 .github/scripts/test_monthly_release_pr.py delete mode 100644 .github/scripts/test_monthly_release_vote.py delete mode 100644 .github/scripts/test_notation_fork_release.py delete mode 100644 .github/scripts/test_notation_release_controller.py delete mode 100644 .github/scripts/test_upstream_authorization.py delete mode 100644 .github/workflows/notation-release-validation.yml diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 965a9c32..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -.github/workflows/*.lock.yml linguist-generated=true diff --git a/.github/MONTHLY_PATCH.md b/.github/MONTHLY_PATCH.md index fa9a5da7..d4a4add8 100644 --- a/.github/MONTHLY_PATCH.md +++ b/.github/MONTHLY_PATCH.md @@ -2,24 +2,29 @@ The monthly patch controller and full proposal live in the companion [notation CLI repository](https://github.com/notaryproject/notation). This -repository installs the shared worker and validation workflow from that change. -It does not run a separate assessment schedule or require a Copilot token. The -shared controller helper supports authorization checks, not a second scheduled -controller. +repository installs only a dispatch workflow that calls the CLI's reusable +worker at a full commit SHA. The CLI owns the shared scripts, regression tests, +assessment schedule, and controller. Go does not require a Copilot token. ## Merge-time state Maintainers must leave the Actions variable `MONTHLY_PATCH_ENABLED` unset or `false` when merging the setup. Merging this change is not authorization to activate automation, change repository settings, or publish a release. The -companion CLI controller and this repository's worker must -both be installed before activation. +companion CLI controller and this repository's caller must both be installed +before activation. The draft's reusable-workflow pin must be rebound to the final +upstream CLI commit after the companion PR merges, before Go merges or activates. +Squash merging can change that commit SHA. +The current review pin is `16fcf79278a01b403233170611f94eb665bdde07`. +Maintainers must update both the caller's `uses` reference and `worker_sha` +input to the same final upstream commit. ## Repository settings | Setting | Required setup | | --- | --- | | Repository features | Enable GitHub Actions and Issues. | +| Actions policy | Allow the reusable workflow `notaryproject/notation/.github/workflows/monthly-patch-worker.yml` and its required actions. The caller uses a full commit SHA, and the central worker checks out its helper code at the corresponding immutable pin. | | Dependabot | Keep Go modules and GitHub Actions updates enabled on `main`. The existing `.github/dependabot.yml` configures both weekly and does not override the default target branch. | | Release branch | Maintain the current latest stable `release-X.Y` branch with required CI checks and reviews. Determine the branch from the current stable release, not from the version on `main`. | | Merge methods | Enable **Allow merge commits**. Preserve the required normal merge method for release voting PRs. | @@ -32,7 +37,9 @@ both be installed before activation. | Actions variable `MONTHLY_PATCH_ENABLED` | Leave unset or `false` until maintainers explicitly authorize activation after companion setup and validation. | Maintainers must verify the configured token can perform the required operations -without bypassing required checks or reviews. +without bypassing required checks or reviews. The caller passes only the two +declared secrets, and passes empty values during `dry-run`; it does not inherit +all repository secrets. ## Coordination and release approvals @@ -69,10 +76,13 @@ required metadata update without granting permission for other source changes. ## Activation 1. Maintainers review and merge the companion CLI controller and the - Go worker, with automation still disabled. + Go caller, with automation still disabled. Before merging Go, rebind its + provisional draft pin to the final upstream CLI commit and review the + central helper checkout pin. 2. Administrators configure the settings above and confirm the required CI and review rules apply to the selected stable release branch. -3. Maintainers review the shared payload's focused test and workflow-lint results +3. Maintainers review the CLI's centralized tests and the Go caller's workflow + lint and contract checks, and approve the controller's authorization policy and activation. 4. Administrators set `MONTHLY_PATCH_ENABLED` to `true` only after those approvals. They can return it to `false` to disable future worker execution. diff --git a/.github/scripts/build_monthly_assets.py b/.github/scripts/build_monthly_assets.py deleted file mode 100644 index 819a1e7b..00000000 --- a/.github/scripts/build_monthly_assets.py +++ /dev/null @@ -1,66 +0,0 @@ -# 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/check_notation_fork_setup.py b/.github/scripts/check_notation_fork_setup.py deleted file mode 100644 index 2ba8b955..00000000 --- a/.github/scripts/check_notation_fork_setup.py +++ /dev/null @@ -1,135 +0,0 @@ -# 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. - -"""Check all fork release prerequisites using metadata, never secret values.""" - -import argparse -import base64 -import datetime -import json -import pathlib - -import monthly_release as release -import notation_release_controller as controller - - -def named_metadata(api, path, field): - items = [] - for page in range(1, 101): - result = api.request(f"{path}?per_page=100&page={page}") - batch = result[field] - items.extend(batch) - if len(items) == result["total_count"]: - return {item["name"]: item for item in items} - if len(batch) < 100: - raise ValueError("Incomplete setup metadata response") - raise ValueError("Setup metadata exceeds the bounded pagination limit") - - -def check(api, month, dependabot_confirmed=False): - result = {"schema": 1, "month": month, "repositories": {}, "ready": True} - actors = {} - for repository in controller.REPOSITORIES: - metadata = controller.fork_metadata(api, repository) - baseline = release.baseline_plan(api, repository, month, "rehearse") - automation = api.request(f"repos/{repository}/branches/main")["commit"]["sha"] - blockers = [] - if not metadata["has_issues"]: - blockers.append("Enable Issues") - if not dependabot_confirmed: - blockers.append("Enable Dependabot version updates in fork Settings > Advanced Security and confirm all three forks") - if not controller.installed_worker(api, repository, automation): - blockers.append("Install the current coordinated worker and fork propagation helpers on main") - for branch in (baseline["main"], baseline["branch"]): - ref = api.optional(f"repos/{repository}/branches/{branch}") - if ref is None: - blockers.append(f"Create reviewed branch {branch}") - continue - commit = ref["commit"]["sha"] - ci = api.optional(f"repos/{repository}/contents/.github/workflows/notation-fork-ci.yml?ref={commit}") - if ci is None: - blockers.append(f"Install fork dependency CI on {branch}") - else: - if ci.get("encoding") != "base64": - raise ValueError("Cannot inspect fork dependency CI") - content = base64.b64decode(ci["content"]).decode("utf-8") - if "qualify_monthly_candidate.py" not in content or " pull_request:" not in content: - blockers.append(f"Use qualification-backed PR CI on {branch}") - if repository == controller.HOST: - try: - release.check_publisher_guard(api, repository, commit) - except ValueError as error: - blockers.append(f"{branch}: {error}") - for directory in release.modules(repository): - manifest = api.manifest(repository, commit, directory) - for producer in release.PROJECTS[repository.split("/")[1]]: - module = f"github.com/notaryproject/{producer}" - if directory != "." and not any(item["Path"] == module for item in manifest.get("Require") or []): - continue - try: - release.requirement(manifest, module, f"yizha1/{producer}", "rehearse") - except ValueError as error: - blockers.append(f"{branch}:{directory}: {error}") - dependabot = api.optional(f"repos/{repository}/contents/.github/dependabot.yml?ref={automation}") - if dependabot is None: - blockers.append("Install the fork Dependabot configuration on main") - else: - if dependabot.get("encoding") != "base64": - raise ValueError("Cannot inspect Dependabot setup") - if f"target-branch: {baseline['main']}" not in base64.b64decode(dependabot["content"]).decode("utf-8"): - blockers.append("Configure Dependabot to target monthly-patch-test-main") - secrets = named_metadata(api, f"repos/{repository}/actions/secrets", "secrets") - for name in ("MONTHLY_PATCH_TOKEN", "MONTHLY_PATCH_SIGNING_KEY"): - if name not in secrets: - blockers.append(f"Install Actions secret {name}") - if repository == controller.HOST and "COPILOT_GITHUB_TOKEN" not in secrets: - blockers.append("Install Actions secret COPILOT_GITHUB_TOKEN") - variables = named_metadata(api, f"repos/{repository}/actions/variables", "variables") - actors[repository] = variables.get("MONTHLY_PATCH_ACTOR", {}).get("value", "") - for name in ("MONTHLY_PATCH_ACTOR", "MONTHLY_PATCH_SIGNER_LOGIN", "MONTHLY_PATCH_SIGNER_EMAIL"): - if not variables.get(name, {}).get("value"): - blockers.append(f"Set Actions variable {name}") - signer = variables.get("MONTHLY_PATCH_SIGNER_LOGIN", {}).get("value", "") - if actors[repository] and signer and actors[repository] != signer: - blockers.append("Use the actor's signing identity for fork producer PRs") - enabled = ["MONTHLY_PATCH_REHEARSAL_ENABLED"] - if repository == controller.HOST: - enabled.append("NOTATION_RELEASE_COORDINATOR_ENABLED") - for name in enabled: - if variables.get(name, {}).get("value") != "true": - blockers.append(f"Set {name}=true after setup review") - result["repositories"][repository] = {"automation_sha": automation, "baseline": baseline["baseline"], "blockers": blockers} - host_actor = actors[controller.HOST] - for repository, state in result["repositories"].items(): - if host_actor and actors[repository] and actors[repository] != host_actor: - state["blockers"].append("Use the same MONTHLY_PATCH_ACTOR as the CLI coordinator") - result["ready"] = all(not state["blockers"] for state in result["repositories"].values()) - return result - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--month", default=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m")) - parser.add_argument("--output", type=pathlib.Path) - parser.add_argument("--confirm-fork-dependabot-enabled", action="store_true", - help="Owner confirmation that Dependabot version updates are enabled in all three fork settings") - args = parser.parse_args() - result = check(release.GitHub(), args.month, args.confirm_fork_dependabot_enabled) - if args.output: - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") - for repository, state in result["repositories"].items(): - print(f"{repository}: {'READY' if not state['blockers'] else 'BLOCKED'}") - for blocker in state["blockers"]: - print(" " + blocker) - raise SystemExit(0 if result["ready"] else 1) diff --git a/.github/scripts/monthly_cli_smoke.py b/.github/scripts/monthly_cli_smoke.py deleted file mode 100644 index 90d873f5..00000000 --- a/.github/scripts/monthly_cli_smoke.py +++ /dev/null @@ -1,115 +0,0 @@ -# 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 deleted file mode 100644 index 5403e436..00000000 --- a/.github/scripts/monthly_release.py +++ /dev/null @@ -1,1150 +0,0 @@ -# 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 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"[A-Za-z0-9_.-]+/(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 not in {"execute", "rehearse"} 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", "rehearse"}: - 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") - else: - if not metadata.get("fork") or repository.split("/")[0] == "notaryproject": - raise ValueError("Rehearsal writes require a fork, never the canonical repositories") - if environment.get("MONTHLY_PATCH_REHEARSAL_ENABLED") != "true": - raise ValueError("Fork rehearsal execution is not enabled") - 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): - 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 - if mode == "rehearse": - expected = f"github.com/{producer_repository}" - if len(replacements) != 1 or replacements[0]["New"]["Path"] != expected: - raise ValueError(f"Fork rehearsal must explicitly track {expected}, not the upstream module") - return replacements[0]["New"]["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", "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 mode == "rehearse": - expected_branch = "monthly-patch-test-" + expected_branch - expected_main = "monthly-patch-test-main" - tag_match = re.fullmatch( - rf"v{major}\.{minor}\.([0-9]+)-monthly-test\.{month.replace('-', '')}", - state.get("tag", ""), - ) - if not tag_match or int(tag_match[1]) <= patch: - raise ValueError("Rehearsal tag escapes the isolated cycle namespace") - elif 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", "verifying", "published"} and not SHA.fullmatch(state.get("commit", "")): - raise ValueError("Cycle has no valid candidate commit") - if state["status"] in {"ready", "tagged", "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"{owner}/{producer}" if mode == "rehearse" else f"notaryproject/{producer}" - releases = list(api.pages(f"repos/{target}/releases")) - candidates = [] - for item in releases: - if item["draft"] or (mode != "rehearse" and item["prerelease"]): - continue - version = semver(item["tag_name"]) - if mode == "rehearse": - match = re.fullmatch(r"(v\d+\.\d+\.\d+)-monthly-test\.\d{6}", item["tag_name"]) - version = semver(match[1]) if match else None - 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: - if mode == "rehearse": - # Without a verified fork release, consumers keep their current dependencies. - continue - 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_public_assets(api, plan): - release = api.request(f"repos/{plan['repository']}/releases/tags/{plan['tag']}") - if release["draft"] or release["prerelease"] != (plan["mode"] == "rehearse"): - 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 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 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 = f"notaryproject/{repository.split('/')[1]}" if mode in {"rehearse", "dry-run"} else repository - baseline = latest_release(list(api.pages(f"repos/{source}/releases"))) - major, minor, patch = semver(baseline["tag_name"]) - branch = f"monthly-patch-test-release-{major}.{minor}" if mode == "rehearse" else f"release-{major}.{minor}" - tag = f"v{major}.{minor}.{patch + 1}" - if mode == "rehearse": - existing = [ - tuple(map(int, match.groups())) - for item in api.pages(f"repos/{repository}/tags") - if (match := re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?", item["name"])) - and tuple(map(int, match.groups()))[:2] == (major, minor) - ] - patch = max([patch, *(item[2] for item in existing)]) - tag = f"v{major}.{minor}.{patch + 1}-monthly-test.{month.replace('-', '')}" - 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": "monthly-patch-test-main" if mode == "rehearse" else "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 fork_propagation_pull, 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 - fork_propagation_pull(api, repository, pull, authorization)) - 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 = "rehearse" if mode == "rehearse" else "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", "verifying", - }: - cycle.state["controller_plan_id"] = authorization["plan_id"] - if mode == "execute" and cycle.state["status"] != "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["branch"] if mode == "rehearse" else 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", "verifying"}: - assert_tag(api, cycle.state) - if cycle.state["status"] == "verifying": - check_public_assets(api, cycle.state) - return {**cycle.state, "action": "verify" if cycle.state["status"] == "verifying" else "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 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 in {"execute", "rehearse"}: - 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: - from notation_release_controller import fork_propagation_pull - return fork_propagation_pull(api, repository, pull, authorization) - 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) - 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}") - 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, 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): - 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 plan["mode"] == "execute" and plan.get("controller_issue"): - from monthly_release_pr import validate - validate(api, plan) - if authorization and authorization["snapshot"].get("verification_recovery") is not None: - raise ValueError("Verification-only recovery cannot build or republish release assets") - assert_tag(api, plan) - 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") - notes = pathlib.Path(os.environ["RUNNER_TEMP"]) / "monthly-release-notes.txt" - notes.write_text( - f"# {plan['repository'].split('/')[1]} {plan['tag']}\n\n" - + ("Fork rehearsal only; not an official release.\n\n" if plan["mode"] == "rehearse" else "") - + 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" - + "\nPost-publication verification is pending; the cycle completes only after it passes.\n", - encoding="utf-8", - ) - releases = list(api.pages(f"repos/{plan['repository']}/releases")) - existing = next((item for item in releases if item["tag_name"] == plan["tag"]), None) - if existing: - # Recovery must use the originally published bytes, not a new rebuild. - manifest = re.findall(r"Asset verification manifest:\n```json\n(.*?)\n```", existing.get("body") or "", re.DOTALL) - if len(manifest) != 1: - raise ValueError("Existing release has no unambiguous asset manifest") - assets = json.loads(manifest[0]) - else: - command = [ - "gh", "release", "create", plan["tag"], "--repo", plan["repository"], - "--verify-tag", "--latest=false", "--title", plan["tag"], - "--notes-file", str(notes), - ] - if plan["mode"] == "rehearse": - command.append("--prerelease") - command.extend(str(pathlib.Path(directory) / item["name"]) for item in assets) - run(command) - plan.update(status="verifying", assets=assets) - check_public_assets(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", "verifying"} or cycle.state["commit"] != plan["commit"]: - raise ValueError("Cycle changed before recording publication") - cycle.save(plan) - return {**plan, "action": "verify"} - - -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.get("commit") != plan["commit"] or cycle.state["status"] != "verifying": - raise ValueError("No matching public release awaiting successful verification") - assert_tag(api, plan) - 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 - if mode == "rehearse" and not api.request(f"repos/{target}").get("fork"): - raise ValueError("Downstream rehearsal notification requires an actual fork") - 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 not in {"execute", "rehearse"}: - 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") - elif not metadata.get("fork") or repository.split("/")[0] == "notaryproject" or os.environ.get("MONTHLY_PATCH_REHEARSAL_ENABLED") != "true": - raise ValueError("Release rehearsal notifications require an enabled fork") - 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 deleted file mode 100644 index 18f55bf0..00000000 --- a/.github/scripts/monthly_release_vote.py +++ /dev/null @@ -1,302 +0,0 @@ -# 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_fork_propagation.py b/.github/scripts/notation_fork_propagation.py deleted file mode 100644 index 51981c3a..00000000 --- a/.github/scripts/notation_fork_propagation.py +++ /dev/null @@ -1,200 +0,0 @@ -# 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. - -"""Open signed, plan-bound fork replacement PRs without bypassing normal CI.""" - -import argparse -import base64 -import hashlib -import json -import os -import pathlib -import tempfile - -import monthly_release as release -import notation_release_controller as controller - - -def module_paths(repository): - return {f"{directory}/go.{extension}".removeprefix("./") - for directory in release.modules(repository) for extension in ("mod", "sum")} - - -def source_head(api, plan, authorization): - cycle = release.Cycle(api, plan["repository"], plan["month"], "rehearse") - if cycle.state is None or cycle.state["status"] != "waiting": - raise ValueError("Fork propagation requires a trusted waiting worker cycle") - controller.worker_authorization(api, plan["repository"], plan["month"], cycle.state) - head = api.request(f"repos/{plan['repository']}/branches/{plan['main']}")["commit"]["sha"] - expected = authorization["snapshot"]["main_head"] - if head != expected: - ahead = controller.compare_commits(api, plan["repository"], expected, head) - merged = cycle.state["merged"] - checkpoint = next((index for index, item in enumerate(merged) if item["commit"] == expected), -1) - approved = {item["commit"] for item in merged[checkpoint + 1:]} - if ahead != approved: - raise ValueError("Isolated main advanced outside the approved dependency merges") - return head - - -def manifests(source, repository): - return {directory: json.loads(release.run(["go", "mod", "edit", "-json"], source / directory)) - for directory in release.modules(repository)} - - -def prepare(api, plan, output): - authorization = controller.worker_authorization(api, plan["repository"], plan["month"], plan) - if authorization is None or plan["mode"] != "rehearse": - raise ValueError("Replacement propagation is restricted to authorized fork rehearsals") - producers = authorization["producers"] - if not producers: - return {"action": "none", "reason": "No planned producer update"} - for pull in api.pulls(plan["repository"], plan["main"], "open"): - if ((release.dependabot(pull, plan["main"]) and controller.scoped_pull(api, plan["repository"], pull, authorization)) - or controller.fork_propagation_pull(api, plan["repository"], pull, authorization)): - return {"action": "none", "reason": "Waiting for existing approved dependency PR checks"} - base = source_head(api, plan, authorization) - if not release.producer_lag(api, plan["repository"], base, "rehearse", producers): - return {"action": "none", "reason": "Planned producers are already consumed"} - producer_modules = {name: api.manifest(item["repository"], item["version"], ".") for name, item in producers.items()} - with tempfile.TemporaryDirectory(prefix="notation-fork-propagation-source-") as temporary: - source = pathlib.Path(temporary) - release.git(["init", "--quiet"], source) - release.git(["remote", "add", "origin", f"https://github.com/{plan['repository']}.git"], source) - release.git(["fetch", "--quiet", "--depth=1", "origin", base], source) - release.git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], source) - if release.git(["rev-parse", "HEAD"], source) != base: - raise ValueError("Fork propagation source changed") - before = manifests(source, plan["repository"]) - original = {path: (source / path).read_text(encoding="utf-8") if (source / path).exists() else None - for path in module_paths(plan["repository"])} - for directory in release.modules(plan["repository"]): - for name, item in producers.items(): - module = f"github.com/notaryproject/{name}" - if not any(entry["Path"] == module for entry in before[directory].get("Require") or []): - continue - release.run(["go", "mod", "edit", f"-replace={module}=github.com/{item['repository']}@{item['version']}"], - source / directory) - release.run(["go", "mod", "tidy"], source / directory) - after = manifests(source, plan["repository"]) - current = release.run(["go", "env", "GOVERSION"]).strip() - if not controller.producer_manifests(before, after, producers, producer_modules, current): - raise ValueError("Propagation would change unrelated direct dependencies; finish their reviewed updates first") - if release.producer_lag_from_manifests(plan["repository"], after, "rehearse", producers): - raise ValueError("Propagation did not consume every planned producer") - changed = set(release.git(["diff", "--name-only", "--no-renames"], source).splitlines()) - changed.update(release.git(["ls-files", "--others", "--exclude-standard"], source).splitlines()) - if not changed or not changed <= module_paths(plan["repository"]): - raise ValueError("Propagation changed files outside module manifests") - files = {path: (source / path).read_text(encoding="utf-8") for path in sorted(changed)} - result = {"schema": 1, "action": "pull-request", "plan": plan, "base": base, - "original": {path: hashlib.sha256(value.encode()).hexdigest() if value is not None else None - for path, value in original.items()}, "files": files} - output.mkdir(parents=True, exist_ok=True) - return result - - -def publish(api, prepared, output): - if prepared.get("action") == "none": - return prepared - if set(prepared) != {"schema", "action", "plan", "base", "original", "files"} or type(prepared["schema"]) is not int or prepared["schema"] != 1: - raise ValueError("Invalid propagation artifact") - plan = prepared["plan"] - authorization = controller.worker_authorization(api, plan["repository"], plan["month"], plan) - if authorization is None or plan["mode"] != "rehearse" or source_head(api, plan, authorization) != prepared["base"]: - raise ValueError("Propagation source or authorization changed") - release.policy(plan["repository"], "rehearse", os.environ, api.request(f"repos/{plan['repository']}")) - files, allowed = prepared["files"], module_paths(plan["repository"]) - if (not files or not set(files) <= allowed or set(prepared["original"]) != allowed - or any(not isinstance(value, str) for value in files.values()) - or sum(len(value.encode()) for value in files.values()) > 2_000_000): - raise ValueError("Invalid or oversized propagation file set") - branch = controller.propagation_branch(plan["month"], authorization["plan_id"]) - for pull in api.pulls(plan["repository"], plan["main"], "open"): - if pull["head"].get("ref") == branch: - if not controller.fork_propagation_pull(api, plan["repository"], pull, authorization): - raise ValueError("Existing propagation branch has an untrusted PR") - return {"action": "waiting", "pull_request": pull["number"], "url": pull["html_url"]} - with tempfile.TemporaryDirectory(prefix="notation-fork-propagation-publish-") as temporary: - source = pathlib.Path(temporary) / "source" - source.mkdir() - release.git(["init", "--quiet"], source) - release.git(["remote", "add", "origin", f"https://github.com/{plan['repository']}.git"], source) - release.git(["fetch", "--quiet", "--depth=1", "origin", prepared["base"]], source) - release.git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], source) - if release.git(["rev-parse", "HEAD"], source) != prepared["base"]: - raise ValueError("Propagation publish checkout changed") - before = manifests(source, plan["repository"]) - for path in sorted(allowed): - file = source / path - actual = hashlib.sha256(file.read_bytes()).hexdigest() if file.exists() else None - if actual != prepared["original"][path]: - raise ValueError("Propagation artifact does not match its immutable source") - for path, content in files.items(): - (source / path).write_text(content, encoding="utf-8") - after = manifests(source, plan["repository"]) - producer_modules = {name: api.manifest(item["repository"], item["version"], ".") - for name, item in authorization["producers"].items()} - if not controller.producer_manifests(before, after, authorization["producers"], producer_modules, - release.run(["go", "env", "GOVERSION"]).strip()): - raise ValueError("Propagation artifact escapes its planned producer scope") - if release.producer_lag_from_manifests(plan["repository"], after, "rehearse", authorization["producers"]): - raise ValueError("Propagation artifact does not consume all planned producers") - with tempfile.TemporaryDirectory(prefix="notation-fork-propagation-signing-") as signing: - environment = release.signing_environment(signing, api) - environment["GIT_AUTHOR_DATE"] = environment["GIT_COMMITTER_DATE"] = plan["started_at"] - release.git(["add", "--", *sorted(files)], source, environment) - release.git(["commit", "-S", "-m", "Update approved Notation fork producer versions", - "-m", "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>"], source, environment) - head = release.git(["rev-parse", "HEAD"], source) - existing = api.optional(f"repos/{plan['repository']}/git/ref/heads/{branch}") - if existing and existing["object"]["sha"] != head: - raise ValueError("Propagation branch exists at a different immutable commit") - if not existing: - release.git(["-c", "push.followTags=false", "push", "--atomic", - f"--force-with-lease=refs/heads/{branch}:", "origin", f"{head}:refs/heads/{branch}"], - source, environment) - receipt = {"schema": 1, "repository": plan["repository"], "month": plan["month"], - "controller_issue": authorization["issue"], "plan_id": authorization["plan_id"], - "producers": authorization["producers"], "base": prepared["base"], "head": head} - marker = f"" - pull = api.request(f"repos/{plan['repository']}/pulls", "POST", { - "title": "Update approved Notation fork producer versions", "head": branch, "base": plan["main"], - "maintainer_can_modify": False, - "body": marker + "\n\nFork rehearsal only. Normal successful CI and required reviews must precede merging.\n\n```json\n" - + json.dumps(receipt, indent=2) + "\n```\n", - }) - if pull["user"]["login"] != os.environ["MONTHLY_PATCH_ACTOR"]: - raise ValueError("Propagation PR was created by an unexpected actor") - return {"action": "waiting", "pull_request": pull["number"], "url": pull["html_url"]} - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("prepare", "publish")) - parser.add_argument("--plan", type=pathlib.Path) - parser.add_argument("--prepared", type=pathlib.Path) - parser.add_argument("--output", type=pathlib.Path, required=True) - args = parser.parse_args() - api = release.GitHub() - if args.command == "prepare": - if args.plan is None: - parser.error("prepare requires --plan") - result = prepare(api, controller.load_json(args.plan.read_text()), args.output) - else: - if args.prepared is None: - parser.error("publish requires --prepared") - result = publish(api, controller.load_json(args.prepared.read_text()), args.output) - args.output.mkdir(parents=True, exist_ok=True) - (args.output / "propagation.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") - print(json.dumps({key: value for key, value in result.items() if key not in {"files", "original", "plan"}}, indent=2)) diff --git a/.github/scripts/notation_release_controller.py b/.github/scripts/notation_release_controller.py deleted file mode 100644 index 3606d9ed..00000000 --- a/.github/scripts/notation_release_controller.py +++ /dev/null @@ -1,1184 +0,0 @@ -# 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 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 - -FORK = os.environ.get("GITHUB_REPOSITORY", "") in { - f"yizha1/{name}" for name in release.PROJECTS -} -OWNER = "yizha1" if FORK else "notaryproject" -HOST = f"{OWNER}/notation" -MODE = "rehearse" if FORK else "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" -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 fork_metadata(api, repository): - if repository not in REPOSITORIES: - raise ValueError("Coordinator repository scope is fixed") - metadata = api.request(f"repos/{repository}") - if bool(metadata.get("fork")) != FORK or metadata.get("private") or metadata.get("default_branch") != "main": - raise ValueError(f"Expected a public {'fork' if FORK else 'canonical repository'} with trusted default main: {repository}") - return metadata - - -def installed_worker(api, repository, commit): - files = { - WORKER_PATH: ("MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'", " workflow_dispatch:"), - ".github/scripts/notation_release_controller.py": ("def worker_authorization(",), - ".github/scripts/monthly_release.py": ("coordinator_authorization(",), - } - if not FORK: - files.update({ - ".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 ",), - }) - for path, required in files.items(): - item = api.optional(f"repos/{repository}/contents/{path}?ref={commit}") - if item is None: - return False - if item.get("encoding") != "base64": - raise ValueError(f"Cannot inspect trusted worker installation: {path}") - content = base64.b64decode(item["content"]).decode("utf-8") - if any(token not in content for token in required): - return False - return True - - -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 - if FORK: - raise ValueError("Assessed source refs changed") - 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 FORK: - if commit != snapshot["automation_sha"]: - raise ValueError("Release worker code changed after assessment") - elif automation_digest(api, repository, commit) != snapshot.get("automation_digest"): - raise ValueError("Release worker code changed after assessment; explicit reassessment is required") - return commit - - -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, - "release_votes": {"notation-core-go": "branch-rules", "notation-go": "repo-maintainer-majority", - "notation": "repo-maintainer-majority"}, - } - if FORK or policy != expected or type(policy.get("schema")) is not int or type(policy.get("maximum_assessment_age_hours")) is not int: - raise ValueError("Unsupported automatic-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 authorize_routine(api, plan): - if FORK or os.environ.get("NOTATION_RELEASE_AUTO_APPROVAL_ENABLED") != "true": - raise ValueError("Automatic cycle authorization is disabled") - 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": "policy", "policy_sha256": digest(policy), "policy_commit": commit} - - -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 = fork_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") - if workflow is None or workflow.get("state") != "active" or not installed_worker(api, repository, automation): - blockers.append("Install and enable the complete coordinator-aware release worker on main") - 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 = {} - if not FORK: - 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], - "ready": ready, "reason": reason}) - 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"]) - merged.append(item) - 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, - **({"automation_digest": automation_digest(api, repository, automation)} if not FORK else {}), - **provenance, - "source_repository": source, "source_commit": commit, "blockers": blockers, - "pulls": pulls, "merged": merged, "manifests": manifests, "scans": scans, - } - 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(): - 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 node["workflow_id"] is not None: - positive_id(node["workflow_id"]) - if not FORK and not DIGEST.fullmatch(node.get("automation_digest", "")): - raise ValueError("Missing automation tree identity") - if not FORK and 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) - if set(assessment) != {"schema", "month", "inventory_sha256", "decisions"}: - 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") if not FORK else ()))} - for repo, node in inventory["repositories"].items() - }, - } - # 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"]} 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 {"active", "completed"}: - raise ValueError("Invalid controller status") - if not FORK: - authorization = state.get("authorization", {}) - if authorization.get("kind") not in {"explicit", "policy"}: - raise ValueError("Missing authorization provenance") - if authorization["kind"] == "policy" and ( - not DIGEST.fullmatch(authorization.get("policy_sha256", "")) - or not release.SHA.fullmatch(authorization.get("policy_commit", "")) - ): - raise ValueError("Invalid policy authorization") - 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] - 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 "" - if marker not in (pull.get("body") or ""): - return False - commit = api.request(f"repos/{repository}/commits/{receipt['head']}") - if not commit["commit"]["verification"]["verified"] or (commit.get("author") or {}).get("login") != os.environ["MONTHLY_PATCH_ACTOR"]: - raise ValueError("Fork propagation PR requires a verified commit by the configured actor") - scoped = copy.deepcopy(pull) - scoped["base"]["sha"] = receipt["base"] - return producer_pull(api, repository, scoped, authorization["producers"]) - - -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 fork_propagation_pull(api, repository, pull, authorization) - 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 = fork_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") - if not FORK: - assessed_automation(api, HOST, expected["snapshots"][HOST], run["head_sha"]) - return expected - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=["collect", "validate", "control"]) - 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 == "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 not FORK and os.environ.get("MONTHLY_PATCH_ENABLED") != "true": - raise ValueError("Canonical monthly release execution is disabled") - if args.assessment_run is None and os.environ.get("NOTATION_RELEASE_AUTO_APPROVAL_ENABLED") == "true": - 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"] == "active"] - 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: - authorization = {"kind": "explicit"} if args.approve else authorize_routine(api, plan) - issue, state = controller.approve(plan, args.assessment_run, args.replan, args.recover_public_verification, authorization) - 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"] == "active"] - 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 deleted file mode 100644 index d9610169..00000000 --- a/.github/scripts/qualify_monthly_candidate.py +++ /dev/null @@ -1,82 +0,0 @@ -# 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/test_monthly_public_release.py b/.github/scripts/test_monthly_public_release.py deleted file mode 100644 index d49c394c..00000000 --- a/.github/scripts/test_monthly_public_release.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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 pathlib -import urllib.request - -from monthly_cli_smoke import smoke -from monthly_release import GitHub, assert_tag, check_public_assets, git, validate_plan -from monthly_release_checks import ( - asset_names, checked, extract_binary, library_source_archive, packaged_e2e, scan, - verify_checksums, verify_metadata, -) - - -def verify(plan, source, platform, evidence): - validate_plan(plan) - if plan["status"] != "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_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: - 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() - expected = recorded[name] - 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, - "public_downloads": True, "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 deleted file mode 100644 index c35ae8ad..00000000 --- a/.github/scripts/test_monthly_release.py +++ /dev/null @@ -1,707 +0,0 @@ -# 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_REHEARSAL_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 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["state"], - "user": {"login": "test-actor"}, - } - 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) - - 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_rehearsal_bumps_above_all_same_line_trial_tags(self): - candidate = release.baseline_plan(FakeGitHub(), "yizha1/notation-core-go", "2026-10", "rehearse") - self.assertEqual(candidate["tag"], "v1.3.4-monthly-test.202610") - self.assertEqual(candidate["branch"], "monthly-patch-test-release-1.3") - release.validate_plan(candidate) - - 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: - if name == "MONTHLY_PATCH_REHEARSAL_ENABLED": - continue - 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("yizha1/notation", "rehearse", ENVIRONMENT, {"fork": True}) - 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") - self.assertEqual(release.requirement(manifest, module, "yizha1/notation-core-go", "rehearse"), replacement["New"]["Version"]) - 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.fork_propagation_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])), []) - fork = FakeGitHub() - list(release.dispatch_consumers(fork, "yizha1/notation-core-go", "rehearse", "v1.3.4-monthly-test.202610")) - self.assertTrue(all(path.startswith("repos/yizha1/") for path, _, _ in fork.writes)) - with self.assertRaises(ValueError): - list(release.dispatch_consumers(api, REPO, "execute", "v1.3.1", ["untrusted/notation"])) - with patch.object(api, "request", side_effect=release.APIError("HTTP 403")), self.assertRaises(release.APIError): - list(release.dispatch_consumers(api, REPO, "execute", "v1.3.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"), "assets": [ - {"name": name, "size": 10, "sha256": "a" * 64} - for name in ("notation-core-go_1.3.1_source.tar.gz", "notation-core-go_1.3.1_checksums.txt") - ]} - public = {"id": 1, "draft": False, "prerelease": False, "body": body(candidate)} - assets = [{"name": item["name"], "size": 10, "digest": "sha256:" + "a" * 64} for item in candidate["assets"]] - asset = assets[0] - with 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(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(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 deleted file mode 100644 index 8c7c20d5..00000000 --- a/.github/scripts/test_monthly_release_checks.py +++ /dev/null @@ -1,234 +0,0 @@ -# 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_advisory_dispositions_are_explicit_scoped_expiring_and_revision_pinned(self): - policy = { - "advisory": "GO-2026-1234", "repository": "yizha1/notation", "line": "1.3", - "owner": "reviewer", "expires": "2099-01-01", "modified": "2026-01-01", - "reason": "Reviewed test condition", "reference": "https://example.com/advisory", - } - advisory = {"modified": "2026-01-01", "affected": [{"ranges": [{"events": [{"introduced": "0"}]}]}]} - self.assertEqual(checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": advisory}), set()) - context = {"GITHUB_REPOSITORY": "yizha1/notation", "MONTHLY_RELEASE_TAG": "v1.3.1", - "MONTHLY_PATCH_ADVISORY_DISPOSITIONS": json.dumps([policy])} - with patch.dict(os.environ, context): - self.assertEqual(checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": advisory}), {"GO-2026-1234"}) - for field, value in (("repository", "other/notation"), ("line", "1.2"), ("expires", "2000-01-01"), ("modified", "changed")): - with patch.dict(os.environ, {**context, "MONTHLY_PATCH_ADVISORY_DISPOSITIONS": json.dumps([{**policy, field: value}])}), self.assertRaises(ValueError): - checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": advisory}) - fixed = {**advisory, "affected": [{"ranges": [{"events": [{"fixed": "1.3.2"}]}]}]} - with patch.dict(os.environ, context), self.assertRaises(ValueError): - checks.accepted_findings({"GO-2026-1234"}, {"GO-2026-1234": fixed}) - - 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-release.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("permissions:\n 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 deleted file mode 100644 index 0bed6300..00000000 --- a/.github/scripts/test_monthly_release_pr.py +++ /dev/null @@ -1,245 +0,0 @@ -# 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 deleted file mode 100644 index e94b0e5d..00000000 --- a/.github/scripts/test_monthly_release_vote.py +++ /dev/null @@ -1,390 +0,0 @@ -# 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_fork_release.py b/.github/scripts/test_notation_fork_release.py deleted file mode 100644 index 818db5fe..00000000 --- a/.github/scripts/test_notation_fork_release.py +++ /dev/null @@ -1,490 +0,0 @@ -# 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 tempfile -import unittest -import zipfile -from unittest.mock import patch - -import monthly_release as release -import check_notation_fork_setup as setup -import notation_fork_propagation as propagation -import notation_release_controller as controller -from test_monthly_release import plan as worker_plan, pull -from test_notation_release_controller import ControllerAPI, CORE, GO, CLI, MAIN, PR_HEAD, MONTH - -PLAN_ID = "1" * 64 -TARGET = "v1.3.4-monthly-test.202610" - - -def authorization(): - return {"issue": 12, "plan_id": PLAN_ID, - "snapshot": {"main": "monthly-patch-test-main", "main_head": MAIN, "tag": TARGET}, - "producers": {"notation-core-go": {"repository": CORE, "version": TARGET}}} - - -def producer_pr(): - candidate = pull(number=99, sha=PR_HEAD, base="monthly-patch-test-main") - candidate["user"] = {"login": "test-actor", "type": "User"} - candidate["base"]["sha"] = MAIN - candidate["head"].update(repo={"full_name": GO}, ref=controller.propagation_branch(MONTH, PLAN_ID)) - receipt = {"schema": 1, "repository": GO, "month": MONTH, "controller_issue": 12, "plan_id": PLAN_ID, - "producers": authorization()["producers"], "base": MAIN, "head": PR_HEAD} - candidate["body"] = f"\n```json\n{json.dumps(receipt)}\n```\n" - return candidate - - -class PropagationAuthorizationTests(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.commit = patch.object(self.api, "request", return_value={ - "commit": {"verification": {"verified": True}}, "author": {"login": "test-actor"}}) - self.commit.start() - self.addCleanup(self.commit.stop) - self.scope = patch.object(controller, "producer_pull", return_value=True) - self.scope.start() - self.addCleanup(self.scope.stop) - - def test_only_signed_actor_pr_with_current_exact_plan_is_accepted(self): - self.assertTrue(controller.fork_propagation_pull(self.api, GO, producer_pr(), authorization())) - for mutate in ( - lambda pr: pr["user"].update(login="different-actor"), - lambda pr: pr["head"].update(sha=MAIN), - lambda pr: pr["head"].update(ref="main"), - lambda pr: pr["head"].update(repo={"full_name": "other/notation-go"}), - lambda pr: pr["base"].update(ref="main"), - lambda pr: pr.update(body="ordinary manually authored dependency update"), - ): - candidate = producer_pr() - mutate(candidate) - with self.subTest(candidate=candidate): - self.assertFalse(controller.fork_propagation_pull(self.api, GO, candidate, authorization())) - self.assertFalse(controller.fork_propagation_pull(self.api, "notaryproject/notation-go", producer_pr(), authorization())) - self.assertFalse(controller.fork_propagation_pull(self.api, GO, producer_pr(), {**authorization(), "plan_id": "2" * 64})) - - def test_unsigned_or_wrong_signer_commit_blocks_merging(self): - for verified, actor in ((False, "test-actor"), (True, "different-actor")): - self.api.request.return_value = {"commit": {"verification": {"verified": verified}}, "author": {"login": actor}} - with self.subTest(verified=verified, actor=actor), self.assertRaisesRegex(ValueError, "verified commit"): - controller.fork_propagation_pull(self.api, GO, producer_pr(), authorization()) - - def test_signed_pr_cannot_hide_unrelated_dependency_changes(self): - controller.producer_pull.return_value = False - self.assertFalse(controller.fork_propagation_pull(self.api, GO, producer_pr(), authorization())) - - def test_closed_pr_uses_immutable_receipt_base_for_scope_validation(self): - candidate = producer_pr() - candidate["base"]["sha"] = "e" * 40 - candidate.update(merged_at="2026-10-05T01:00:00Z", merge_commit_sha="f" * 40) - self.assertTrue(controller.fork_propagation_pull(self.api, GO, candidate, authorization())) - self.assertEqual(controller.producer_pull.call_args.args[2]["base"]["sha"], MAIN) - - def test_new_authorized_actor_pr_can_enter_worker_inventory(self): - candidate = producer_pr() - candidate.update(merged_at="2026-10-05T01:00:00Z", merge_commit_sha="f" * 40) - self.api.closed_pulls = [candidate] - self.assertEqual(release.inventory(self.api, GO, "monthly-patch-test-main", authorization())[0]["number"], 99) - self.assertEqual(release.inventory(self.api, GO, "monthly-patch-test-main"), []) - - def test_plan_without_producers_never_authorizes_actor_updates(self): - self.assertFalse(controller.fork_propagation_pull(self.api, GO, producer_pr(), {**authorization(), "producers": {}})) - - -class PropagationPipelineTests(unittest.TestCase): - def test_real_go_update_creates_one_signed_module_only_pr_and_resumes(self): - self.check_real_go_update() - - def test_real_go_update_includes_a_producer_required_direct_dependency_bump(self): - self.check_real_go_update(direct_dependency=True) - - def check_real_go_update(self, direct_dependency=False): - with tempfile.TemporaryDirectory(prefix="notation-fork-integration-") as temporary: - root = pathlib.Path(temporary) - proxy, tree, remote = root / "proxy", root / "tree", root / "remote.git" - tree.mkdir() - old = "v1.3.3-monthly-test.202609" - module = "github.com/yizha1/notation-core-go" - versions = proxy / module / "@v" - versions.mkdir(parents=True) - for version in (old, TARGET): - mod = "module github.com/notaryproject/notation-core-go\n\ngo 1.26.0\n" - if direct_dependency: - mod += "\nrequire example.com/shared " + ("v1.0.0" if version == old else "v1.2.0") + "\n" - (versions / (version + ".mod")).write_text(mod) - (versions / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-01T00:00:00Z"})) - with zipfile.ZipFile(versions / (version + ".zip"), "w") as archive: - archive.writestr(module + "@" + version + "/go.mod", mod) - body = ('package core\n\nimport "example.com/shared"\n\nfunc Value() int { return shared.Value() }\n' - if direct_dependency else "package core\n\nfunc Value() int { return 1 }\n") - archive.writestr(module + "@" + version + "/core.go", body) - if direct_dependency: - shared = proxy / "example.com/shared" / "@v" - shared.mkdir(parents=True) - for version in ("v1.0.0", "v1.2.0"): - mod = "module example.com/shared\n\ngo 1.26.0\n" - (shared / (version + ".mod")).write_text(mod) - (shared / (version + ".info")).write_text(json.dumps({"Version": version, "Time": "2026-10-01T00:00:00Z"})) - with zipfile.ZipFile(shared / (version + ".zip"), "w") as archive: - archive.writestr("example.com/shared@" + version + "/go.mod", mod) - archive.writestr("example.com/shared@" + version + "/shared.go", "package shared\n\nfunc Value() int { return 1 }\n") - (tree / "go.mod").write_text( - "module github.com/notaryproject/notation-go\n\ngo 1.26.0\n\n" - "require github.com/notaryproject/notation-core-go v1.3.0\n\n" - f"replace github.com/notaryproject/notation-core-go => {module} {old}\n" - ) - (tree / "main.go").write_text( - 'package notation\n\nimport core "github.com/notaryproject/notation-core-go"\n\nfunc Value() int { return core.Value() }\n' - ) - if direct_dependency: - (tree / "go.mod").write_text((tree / "go.mod").read_text() + "\nrequire example.com/shared v1.0.0\n") - (tree / "main.go").write_text( - 'package notation\n\nimport (core "github.com/notaryproject/notation-core-go"; "example.com/shared")\n\n' - "func Value() int { return core.Value() + shared.Value() }\n" - ) - environment = {"GOWORK": "off", "GOTOOLCHAIN": "local", "GOPATH": str(root / "go"), - "GOMODCACHE": str(root / "modules"), "GOPROXY": proxy.as_uri(), "GOSUMDB": "off", - "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", - "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", "MONTHLY_PATCH_TOKEN_READY": "true", - "MONTHLY_PATCH_REHEARSAL_ENABLED": "true"} - with patch.dict(os.environ, environment): - release.run(["go", "mod", "tidy"], tree) - release.run(["git", "init", "--quiet"], tree) - release.run(["git", "add", "."], tree) - release.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", - "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Baseline"], tree) - base = release.run(["git", "rev-parse", "HEAD"], tree).strip() - release.run(["git", "init", "--bare", "--quiet", str(remote)]) - release.run(["git", "push", "--quiet", str(remote), "HEAD:refs/heads/monthly-patch-test-main"], tree) - key = root / "key" - release.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)]) - public = key.with_suffix(".pub").read_text() - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - auth = authorization() - auth["snapshot"]["main_head"] = base - plan.update(controller_issue=12, controller_plan_id=PLAN_ID, producers=auth["producers"]) - before = propagation.manifests(tree, GO)["."] - - class LocalAPI(ControllerAPI): - def optional(self, path): - if "/git/ref/heads/" in path: - return None - return super().optional(path) - - def request(self, path, method="GET", payload=None): - if "/branches/" in path: - return {"commit": {"sha": base}} - if method == "POST" and path.endswith("/pulls"): - self.writes.append((path, method, payload)) - head = release.run(["git", f"--git-dir={remote}", "rev-parse", payload["head"]]).strip() - item = {"number": 99, "html_url": "https://github.com/yizha1/notation-go/pull/99", - "user": {"login": "test-actor"}, "body": payload["body"], - "base": {"ref": plan["main"], "sha": base}, - "head": {"ref": payload["head"], "sha": head, "repo": {"full_name": GO}}} - self.open_pulls.append(item) - return item - if "/commits/" in path: - return {"commit": {"verification": {"verified": True}}, "author": {"login": "test-actor"}} - return super().request(path, method, payload) - - def manifest(self, repository, ref, directory): - if repository == CORE: - return {"Go": "1.26.0", "Require": [{"Path": "example.com/shared", "Version": "v1.2.0"}] - if direct_dependency else []} - if ref == base: - return before - modfile = root / "remote.mod" - modfile.write_text(release.run(["git", f"--git-dir={remote}", "show", ref + ":go.mod"])) - return json.loads(release.run(["go", "mod", "edit", "-json", "-modfile=" + str(modfile)])) - - api = LocalAPI() - api.public_key = public - actual_git = release.git - def local_git(command, directory, environment=None): - if command[:3] == ["remote", "add", "origin"]: - command = [*command[:3], str(remote)] - return actual_git(command, directory, environment) - with patch.dict(os.environ, {"MONTHLY_PATCH_SIGNING_KEY": key.read_text()}), patch.object( - controller, "worker_authorization", return_value=auth - ), patch.object(release, "Cycle") as cycle, patch.object(release, "git", side_effect=local_git): - cycle.return_value.state = plan - prepared = propagation.prepare(api, plan, root / "prepared") - self.assertEqual(set(prepared["files"]), {"go.mod", "go.sum"}) - result = propagation.publish(api, prepared, root / "published") - self.assertEqual(result["pull_request"], 99) - head = api.open_pulls[0]["head"]["sha"] - if direct_dependency: - requirements = {entry["Path"]: entry["Version"] for entry in api.manifest(GO, head, ".")["Require"]} - self.assertEqual(requirements["example.com/shared"], "v1.2.0") - signers = root / "allowed-signers" - signers.write_text("test@example.com " + public) - release.run(["git", "-c", "gpg.ssh.allowedSignersFile=" + str(signers), - f"--git-dir={remote}", "verify-commit", head]) - names = release.run(["git", f"--git-dir={remote}", "diff", "--name-only", base, head]).splitlines() - self.assertEqual(set(names), {"go.mod", "go.sum"}) - self.assertEqual(propagation.publish(api, prepared, root / "retry")["pull_request"], 99) - self.assertEqual(len(api.writes), 1) - - def test_direct_dependency_changes_are_limited_to_the_producer_required_floor(self): - before = {"Go": "1.26.0", - "Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.0"}, - {"Path": "example.com/shared", "Version": "v1.0.0"}], - "Replace": [{"Old": {"Path": "github.com/notaryproject/notation-core-go"}, - "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.1-trial.4"}}]} - after = copy.deepcopy(before) - after["Replace"][0]["New"]["Version"] = TARGET - metadata = {"notation-core-go": {"Go": "1.26.0", - "Require": [{"Path": "example.com/shared", "Version": "v1.2.0"}]}} - for version, expected in (("v1.0.0", False), ("v1.1.0", False), ("v1.2.0", True), ("v1.3.0", False)): - after["Require"][1]["Version"] = version - with self.subTest(version=version): - self.assertEqual(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], - metadata, "go1.27.1"), expected) - before["Require"][1]["Version"] = "v1.3.0" - after["Require"][1]["Version"] = "v1.2.0" - self.assertFalse(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], - metadata, "go1.27.1")) - after["Require"][1]["Version"] = "v1.3.0" - self.assertTrue(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], - metadata, "go1.27.1")) - before["Require"][1]["Version"] = "v1.0.0" - after["Require"][1]["Version"] = "v1.2.0" - after["Require"].append({"Path": "example.com/unrelated", "Version": "v1.0.0"}) - self.assertFalse(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], - metadata, "go1.27.1")) - after["Require"].pop() - after["Replace"][0]["New"]["Version"] = before["Replace"][0]["New"]["Version"] - self.assertFalse(controller.producer_manifests({".": before}, {".": after}, authorization()["producers"], - metadata, "go1.27.1")) - - def test_module_version_ordering_matches_go_semver_precedence(self): - ordered = ["v1.0.0-alpha", "v1.0.0-alpha.1", "v1.0.0-alpha.beta", "v1.0.0-beta", - "v1.0.0-beta.2", "v1.0.0-beta.11", "v1.0.0-rc.1", "v1.0.0", "v1.2.0", "v1.10.0"] - self.assertEqual(sorted(reversed(ordered), key=controller.module_version), ordered) - self.assertEqual(controller.module_version("v2.0.0+incompatible"), controller.module_version("v2.0.0")) - for invalid in ("v01.0.0", "v1.0.0-01", "v1.0", "v1.0.0+", None): - with self.subTest(version=invalid), self.assertRaises(ValueError): - controller.module_version(invalid) - - def test_shared_dependency_uses_highest_changed_producer_requirement(self): - producers = {name: {"repository": f"yizha1/{name}", "version": TARGET} - for name in ("notation-core-go", "notation-go")} - before = {"Go": "1.26.0", - "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} for name in producers] - + [{"Path": "example.com/shared", "Version": "v1.0.0"}], - "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, - "New": {"Path": f"github.com/yizha1/{name}", "Version": "v1.3.1-trial.4"}} - for name in producers]} - after = copy.deepcopy(before) - for replacement in after["Replace"]: - replacement["New"]["Version"] = TARGET - metadata = {name: {"Go": "1.26.0", "Require": [{"Path": "example.com/shared", "Version": version}]} - for name, version in zip(producers, ("v1.10.0", "v1.2.0"))} - for version, expected in (("v1.2.0", False), ("v1.10.0", True)): - after["Require"][-1]["Version"] = version - with self.subTest(version=version): - self.assertEqual(controller.producer_manifests({".": before}, {".": after}, producers, metadata, "go1.27.1"), - expected) - - def test_source_head_must_contain_only_recorded_approved_merges(self): - api = ControllerAPI() - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - plan["merged"] = [{"number": 1, "commit": "c" * 40}] - with patch.object(release, "Cycle") as cycle, patch.object(controller, "worker_authorization"), patch.object( - controller, "compare_commits", return_value={"d" * 40} - ), self.assertRaisesRegex(ValueError, "outside the approved"): - cycle.return_value.state = plan - propagation.source_head(api, plan, {**authorization(), "snapshot": {**authorization()["snapshot"], "main_head": PR_HEAD}}) - - def test_no_plan_and_no_producer_cannot_generate_updates(self): - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - with tempfile.TemporaryDirectory() as temporary, patch.object(controller, "worker_authorization", return_value=None): - with self.assertRaisesRegex(ValueError, "authorized fork"): - propagation.prepare(ControllerAPI(), plan, pathlib.Path(temporary)) - with tempfile.TemporaryDirectory() as temporary, patch.object( - controller, "worker_authorization", return_value={**authorization(), "producers": {}} - ): - self.assertEqual(propagation.prepare(ControllerAPI(), plan, pathlib.Path(temporary))["action"], "none") - - def test_existing_approved_dependency_pr_waits_without_duplicate_creation(self): - api = ControllerAPI() - api.open_pulls = [pull(base="monthly-patch-test-main")] - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - with tempfile.TemporaryDirectory() as temporary, patch.object( - controller, "worker_authorization", return_value=authorization() - ), patch.object(controller, "scoped_pull", return_value=True), patch.object(propagation, "source_head") as head: - result = propagation.prepare(api, plan, pathlib.Path(temporary)) - self.assertEqual(result["action"], "none") - head.assert_not_called() - self.assertEqual(api.writes, []) - - def test_malformed_and_out_of_scope_artifacts_fail_before_checkout(self): - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - valid = {"schema": 1, "action": "pull-request", "plan": plan, "base": MAIN, - "original": {name: None for name in propagation.module_paths(GO)}, - "files": {"go.mod": "module example\n"}} - for artifact in ( - {**valid, "schema": True}, - {**valid, "files": {"../../outside": "content"}}, - {**valid, "files": {"go.mod": 42}}, - {**valid, "files": {"go.mod": "x" * 2_000_001}}, - {**valid, "original": {}}, - ): - with tempfile.TemporaryDirectory() as temporary, patch.object( - controller, "worker_authorization", return_value=authorization() - ), patch.object(propagation, "source_head", return_value=MAIN), patch.object(release, "policy"), patch.object( - release, "git" - ) as git, self.subTest(artifact=list(artifact)), self.assertRaises(ValueError): - propagation.publish(ControllerAPI(), artifact, pathlib.Path(temporary)) - git.assert_not_called() - - def test_source_job_has_no_signing_or_write_credential(self): - workflow = pathlib.Path(__file__).parent.parent / "workflows/monthly-patch-release.yml" - source = workflow.read_text().split(" package:\n", 1)[1].split(" publish:\n", 1)[0] - self.assertNotIn("secrets.", source) - self.assertNotIn("MONTHLY_PATCH_SIGNING_KEY", source) - publisher = workflow.read_text().split(" publish:\n", 1)[1].split(" verify:\n", 1)[0] - self.assertNotIn("go mod tidy", publisher) - self.assertIn("monthly_release.py publish", publisher) - self.assertNotIn("build_monthly_assets.py", publisher) - - def test_fork_ci_covers_same_repo_prs_and_uses_no_release_secrets(self): - workflow = (pathlib.Path(__file__).parent.parent / "workflows/notation-release-validation.yml").read_text() - self.assertIn(" pull_request:", workflow) - self.assertIn("test_upstream_authorization.py", workflow) - self.assertNotIn("secrets.", workflow) - self.assertNotIn("head.repo.full_name !=", workflow) - - -class SetupReadinessTests(unittest.TestCase): - def setUp(self): - class SetupAPI(ControllerAPI): - def __init__(self): - super().__init__() - self.missing = set() - self.secrets = {repo: ["MONTHLY_PATCH_TOKEN", "MONTHLY_PATCH_SIGNING_KEY"] - for repo in controller.REPOSITORIES} - self.secrets[CLI].append("COPILOT_GITHUB_TOKEN") - self.variables = {repo: { - "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", - "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", "MONTHLY_PATCH_REHEARSAL_ENABLED": "true", - "NOTATION_RELEASE_COORDINATOR_ENABLED": "true", - } for repo in controller.REPOSITORIES} - - def request(self, path, method="GET", payload=None): - repository = "/".join(path.split("/")[1:3]) - if "/actions/secrets?" in path: - entries = [{"name": name} for name in self.secrets[repository]] - return {"total_count": len(entries), "secrets": entries} - if "/actions/variables?" in path: - entries = [{"name": name, "value": value} for name, value in self.variables[repository].items()] - return {"total_count": len(entries), "variables": entries} - return super().request(path, method, payload) - - def optional(self, path): - if any(value in path for value in self.missing): - return None - if "contents/" in path: - value = ("target-branch: monthly-patch-test-main\n" if "dependabot.yml" in path - else " pull_request:\nqualify_monthly_candidate.py\n") - return {"encoding": "base64", "content": base64.b64encode(value.encode()).decode()} - return self.request(path) - - def manifest(self, repository, commit, directory="."): - producers = release.PROJECTS[repository.split("/")[1]] - return { - "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} - for name in producers], - "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, - "New": {"Path": f"github.com/yizha1/{name}", "Version": TARGET}} - for name in producers], - } - - self.api = SetupAPI() - for name, value in ( - ("fork_metadata", {"has_issues": True}), - ("installed_worker", True), - ): - mocked = patch.object(controller, name, return_value=value) - mocked.start() - self.addCleanup(mocked.stop) - for name, value in ( - ("baseline_plan", {"main": "monthly-patch-test-main", "branch": "monthly-patch-test-release-1.3", - "baseline": "v1.3.0"}), - ("check_publisher_guard", None), - ): - mocked = patch.object(release, name, return_value=value) - mocked.start() - self.addCleanup(mocked.stop) - - def test_complete_fork_setup_is_ready_without_writes(self): - result = setup.check(self.api, MONTH, dependabot_confirmed=True) - self.assertTrue(result["ready"]) - self.assertEqual(set(result["repositories"]), set(controller.REPOSITORIES)) - self.assertEqual(self.api.writes, []) - - def test_configuration_does_not_prove_dependabot_enabled_on_a_fork(self): - result = setup.check(self.api, MONTH) - self.assertFalse(result["ready"]) - for state in result["repositories"].values(): - self.assertEqual(len(state["blockers"]), 1) - self.assertIn("Enable Dependabot version updates", state["blockers"][0]) - - def test_missing_branch_ci_and_token_are_reported_together(self): - self.api.missing.update((f"repos/{CORE}/contents/.github/workflows/notation-fork-ci.yml", - f"repos/{CLI}/branches/monthly-patch-test-release-1.3")) - self.api.secrets[GO].remove("MONTHLY_PATCH_TOKEN") - result = setup.check(self.api, MONTH, dependabot_confirmed=True) - self.assertFalse(result["ready"]) - self.assertEqual(len(result["repositories"][CORE]["blockers"]), 2) - self.assertIn("Install Actions secret MONTHLY_PATCH_TOKEN", result["repositories"][GO]["blockers"]) - self.assertIn("Create reviewed branch monthly-patch-test-release-1.3", result["repositories"][CLI]["blockers"]) - - def test_actor_and_signer_must_match_the_coordinator(self): - self.api.variables[CORE]["MONTHLY_PATCH_ACTOR"] = "other-actor" - self.api.variables[CORE]["MONTHLY_PATCH_SIGNER_LOGIN"] = "other-actor" - self.api.variables[GO]["MONTHLY_PATCH_SIGNER_LOGIN"] = "different-signer" - result = setup.check(self.api, MONTH, dependabot_confirmed=True) - self.assertFalse(result["ready"]) - self.assertIn("Use the same MONTHLY_PATCH_ACTOR as the CLI coordinator", result["repositories"][CORE]["blockers"]) - self.assertIn("Use the actor's signing identity for fork producer PRs", result["repositories"][GO]["blockers"]) - - def test_metadata_collection_paginates_without_reading_secret_values(self): - class MetadataAPI: - def __init__(self): - self.paths = [] - def request(self, path): - self.paths.append(path) - page = int(path.rsplit("=", 1)[1]) - names = range(100) if page == 1 else range(100, 101) - return {"total_count": 101, "secrets": [{"name": f"SECRET_{number}"} for number in names]} - api = MetadataAPI() - self.assertEqual(len(setup.named_metadata(api, "repos/example/repo/actions/secrets", "secrets")), 101) - self.assertEqual(len(api.paths), 2) - self.assertTrue(all("/actions/secrets?" in path for path in api.paths)) - - def test_incomplete_setup_metadata_fails_explicitly(self): - api = ControllerAPI() - with patch.object(api, "request", return_value={"total_count": 2, "secrets": [{"name": "ONE"}]}), self.assertRaisesRegex(ValueError, "Incomplete"): - setup.named_metadata(api, "repos/example/repo/actions/secrets", "secrets") - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/test_notation_release_controller.py b/.github/scripts/test_notation_release_controller.py deleted file mode 100644 index 6c5c25ac..00000000 --- a/.github/scripts/test_notation_release_controller.py +++ /dev/null @@ -1,826 +0,0 @@ -# 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 -import notation_fork_propagation as propagation -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 = (character * 40 for character in "abcd") -MONTH = "2026-10" - - -def inventory(changes=()): - result = { - "schema": 1, "host": coordinator.HOST, "month": MONTH, "collected_at": coordinator.utc_now(), - "repositories": {}, - } - for index, repository in enumerate(coordinator.REPOSITORIES): - result["repositories"][repository] = { - "baseline": "v1.3.0", "main": "monthly-patch-test-main", "branch": "monthly-patch-test-release-1.3", - "tag": "v1.3.4-monthly-test.202610", "main_head": MAIN, "release_head": BRANCH, - "automation_sha": AUTOMATION, "workflow_id": index + 10, - "source_repository": repository, "source_commit": BRANCH, "blockers": [], - "pulls": [{"number": 1, "head": PR_HEAD, "title": "Dependency update", "body": "Untrusted text", - "ready": True, "reason": ""}] if repository in changes else [], - "merged": [], "manifests": {directory: {} for directory in release.modules(repository)}, - "scans": {directory: {"reachable": [], "informational": [], "advisories": {}} - for directory in release.modules(repository)}, - } - return result - - -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 ControllerAPI(FakeGitHub): - def __init__(self): - super().__init__() - self.issues_by_repo = {repo: [] for repo in coordinator.REPOSITORIES} - self.workflow_runs = {} - self.dispatched = [] - self.changed = {} - - def request(self, path, method="GET", payload=None): - repository = "/".join(path.split("/")[1:3]) - suffix = "/".join(path.split("/")[3:]) - if suffix == "issues" and method == "POST" or suffix.startswith("issues/") and method == "PATCH": - return super().request(path, method, payload) - if method == "POST" and (suffix == "dispatches" or suffix.endswith("/dispatches")): - self.dispatched.append((repository, payload)) - self.writes.append((path, method, payload)) - return None - if method != "GET": - raise AssertionError((path, method)) - if not suffix: - return {"fork": True, "private": False, "default_branch": "main", "has_issues": True} - if suffix.startswith("issues/"): - return next(item for item in self.issues_by_repo[repository] if item["number"] == int(suffix.rsplit("/", 1)[1])) - if suffix.startswith("branches/"): - name = suffix.removeprefix("branches/") - value = AUTOMATION if name == "main" else MAIN if name.endswith("-main") else BRANCH - return {"commit": {"sha": self.changed.get((repository, name), value)}} - if suffix == "actions/workflows/monthly-patch-release.yml": - return {"id": coordinator.REPOSITORIES.index(repository) + 10, "state": "active"} - if "/runs?" in suffix: - return {"workflow_runs": list(self.workflow_runs.get(repository, {}).values())} - if suffix.startswith("actions/runs/"): - return self.workflow_runs[repository][int(suffix.rsplit("/", 1)[1])] - return super().request(path, method, payload) - - def worker(self, repository, issue, state, status="published", conclusion="success"): - node = state["nodes"][repository] - run_id = len(self.workflow_runs) + 100 - run = { - "id": run_id, "actor": {"login": "test-actor"}, "head_sha": AUTOMATION, - "workflow_id": coordinator.REPOSITORIES.index(repository) + 10, - "event": "workflow_dispatch", "status": "completed", "conclusion": conclusion, - "display_title": coordinator.run_title(MONTH, state["plan"]["plan_id"], issue["number"], node["attempt"]), - } - self.workflow_runs.setdefault(repository, {})[run_id] = run - candidate = worker_plan(repository, "rehearse", MONTH, status) - candidate.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], - producers={}, assets=[]) - parents = [name for name in release.PROJECTS[repository.split("/")[1]] - if state["nodes"][f"yizha1/{name}"]["status"] == "published"] - for directory in release.modules(repository): - self.manifests[(repository, candidate["commit"], directory)] = { - "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} for name in parents], - "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, - "New": {"Path": f"github.com/yizha1/{name}", "Version": state["nodes"][f"yizha1/{name}"]["version"]}} - for name in parents], - } - existing = [issue for issue in self.issues_by_repo[repository] if coordinator.decode_issue(issue, "test-actor")] - number = max([issue["number"] for issue in existing] + [0]) + 1 - self.issues_by_repo[repository] = existing + [ - {"number": number, "state": "closed" if status in release.FINAL else "open", - "user": {"login": "test-actor"}, "body": body(candidate)} - ] - return run - - -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"], "notaryproject/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"] = ["yizha1/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: " workflow_dispatch:\nMONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'\n", - ".github/scripts/notation_release_controller.py": "def worker_authorization(", - ".github/scripts/monthly_release.py": "coordinator_authorization(", - ".github/scripts/notation_fork_propagation.py": "def prepare(\ndef publish(", - } - def file(path): - self.assertTrue(path.endswith("?ref=" + AUTOMATION)) - name = path.split("/contents/", 1)[1].split("?")[0] - 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 ReleaseControllerTests(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 = coordinator.Controller(self.api, "test-actor") - - def approved(self, changes=(CORE,), decisions=("release", "release", "release")): - evidence = inventory(changes) - plan = coordinator.validate_assessment(evidence, assessment(evidence, decisions)) - return self.controller.approve(plan, 22) - - def test_ordered_releases_are_dispatched_only_after_public_verification(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.assertEqual([repository for repository, _ in self.api.dispatched], [CORE]) - issue, state = self.controller.advance(issue, state) - self.assertEqual(len(self.api.dispatched), 1) - self.api.worker(CORE, issue, state) - with patch.object(release, "assert_tag") as signature, patch.object(release, "check_public_assets") as assets: - issue, state = self.controller.advance(issue, state) - signature.assert_called_once() - assets.assert_called_once() - self.assertEqual([repository for repository, _ in self.api.dispatched], [CORE, GO]) - self.api.worker(GO, issue, state) - issue, state = self.controller.advance(issue, state) - self.assertEqual([repository for repository, _ in self.api.dispatched], [CORE, GO, CLI]) - self.api.worker(CLI, issue, state) - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["status"], "completed") - self.assertEqual(issue["state"], "closed") - self.controller.advance(issue, state) - self.assertEqual(len(self.api.dispatched), 3) - - def test_go_and_cli_only_skip_unnecessary_work(self): - for changes, decisions, expected in ( - ((GO,), ("skip", "release", "release"), GO), - ((CLI,), ("skip", "skip", "release"), CLI), - ): - self.api = ControllerAPI() - self.controller = coordinator.Controller(self.api, "test-actor") - issue, state = self.approved(changes, decisions) - self.controller.advance(issue, state) - self.assertEqual(self.api.dispatched[0][0], expected) - - def test_no_change_cycle_finishes_without_dispatch(self): - issue, state = self.approved((), ("skip", "skip", "skip")) - self.assertEqual(state["status"], "completed") - self.controller.advance(issue, state) - self.assertEqual(self.api.dispatched, []) - - def test_deferred_producer_prevents_consumer_release(self): - with self.assertRaisesRegex(ValueError, "Resolve deferrals"): - self.approved((CORE,), ("defer", "release", "release")) - self.assertEqual(self.api.dispatched, []) - self.assertEqual(self.api.writes, []) - - def test_failed_worker_requires_explicit_retry_and_keeps_patch_version(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state, "ready", "failure") - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["nodes"][CORE]["status"], "failed") - self.controller.advance(issue, state) - self.assertEqual(len(self.api.dispatched), 1) - issue, state = self.controller.advance(issue, state, retry_failed=True) - self.assertEqual(state["nodes"][CORE]["attempt"], 2) - self.assertEqual(state["plan"]["snapshots"][CORE]["tag"], "v1.3.4-monthly-test.202610") - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, CORE]) - - def test_waiting_dependency_worker_is_resumed_not_replanned(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state, "waiting") - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["nodes"][CORE]["status"], "pending") - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["nodes"][CORE]["attempt"], 2) - self.assertEqual(len(self.api.dispatched), 2) - - def test_in_progress_worker_does_not_allow_consumers(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state, "verifying")["status"] = "in_progress" - self.controller.advance(issue, state) - self.assertEqual(len(self.api.dispatched), 1) - - def test_verified_completion_wakes_next_worker_before_callback_finishes(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state)["status"] = "in_progress" - with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): - self.controller.advance(issue, state) - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO]) - - def test_explicit_reassessment_refreshes_refs_without_another_patch(self): - issue, state = self.approved() - evidence = inventory((CORE,)) - evidence["repositories"][CORE]["pulls"][0]["head"] = "e" * 40 - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) - issue, state = self.controller.approve(plan, 23, replan=True) - self.assertEqual(state["plan"]["snapshots"][CORE]["pulls"][0]["head"], "e" * 40) - self.assertEqual(state["plan"]["snapshots"][CORE]["tag"], "v1.3.4-monthly-test.202610") - self.assertEqual(state["revisions"][0]["source_run"], 23) - - def test_reassessment_cannot_modify_inflight_or_public_version(self): - issue, state = self.approved() - evidence = inventory((CORE,)) - evidence["repositories"][CORE]["pulls"][0]["head"] = "e" * 40 - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) - self.controller.advance(issue, state) - with self.assertRaisesRegex(ValueError, "in-flight"): - self.controller.approve(plan, 23, replan=True) - state["nodes"][CORE]["status"] = "pending" - self.controller.save(issue, state) - with patch.object(self.api, "optional", return_value={"object": {"sha": MAIN}}), self.assertRaisesRegex(ValueError, "public candidate"): - self.controller.approve(plan, 23, replan=True) - - def verification_reassessment(self): - issue, state = self.approved() - self.api.worker(CORE, issue, state, "verifying", "failure") - state["nodes"][CORE]["status"] = "failed" - self.controller.save(issue, state) - evidence = inventory((GO,)) - source = evidence["repositories"][CORE] - source.update(release_head=MAIN, source_commit=MAIN, automation_sha="e" * 40) - self.api.changed[(CORE, source["branch"])] = MAIN - self.api.changed[(CORE, "main")] = "e" * 40 - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("skip", "release", "release"))) - return issue, state, plan - - def recover_verification(self, plan): - original = self.api.optional - def tag(path): - return {"object": {"sha": MAIN}} if path.startswith(f"repos/{CORE}/git/ref/tags/") else original(path) - with patch.object(self.api, "optional", side_effect=tag), \ - patch.object(release, "assert_tag") as signature, \ - patch.object(release, "check_public_assets") as assets: - result = self.controller.approve(plan, 23, replan=True, recover_public_verification=True) - signature.assert_called_once() - assets.assert_called_once() - return result - - def test_explicit_public_recovery_changes_only_worker_code_and_binds_asset_identity(self): - issue, previous, plan = self.verification_reassessment() - old_snapshot = copy.deepcopy(previous["plan"]["snapshots"][CORE]) - issue, state = self.recover_verification(plan) - snapshot = state["plan"]["snapshots"][CORE] - self.assertEqual({key: snapshot[key] for key in old_snapshot if key != "automation_sha"}, - {key: old_snapshot[key] for key in old_snapshot if key != "automation_sha"}) - self.assertEqual(snapshot["automation_sha"], "e" * 40) - self.assertEqual(snapshot["verification_recovery"]["from_plan_id"], previous["plan"]["plan_id"]) - self.assertEqual(state["plan"]["decisions"][CORE]["decision"], "release") - self.assertEqual(issue["number"], 1) - self.assertEqual(state["nodes"][CORE]["status"], "pending") - self.assertFalse(self.api.dispatched) - coordinator.validate_state(state) - - def test_verification_recovery_cannot_start_a_new_cycle_or_use_the_old_assessment(self): - evidence = inventory((CORE,)) - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) - with self.assertRaisesRegex(ValueError, "existing approved cycle"): - self.controller.approve(plan, 22, replan=True, recover_public_verification=True) - self.controller.approve(plan, 22) - with self.assertRaisesRegex(ValueError, "fresh changed assessment"): - self.controller.approve(plan, 23, replan=True, recover_public_verification=True) - with self.assertRaisesRegex(ValueError, "explicit replan"): - self.controller.approve(plan, 23, recover_public_verification=True) - - def test_public_recovery_cannot_replace_tagged_but_unpublished_candidate(self): - issue, state, plan = self.verification_reassessment() - cycle = release.Cycle(self.api, CORE, MONTH, "rehearse") - cycle.state["status"] = "tagged" - cycle.save(cycle.state) - with self.assertRaisesRegex(ValueError, "exact public verification candidate"): - self.recover_verification(plan) - - def test_public_recovery_cannot_change_candidate_branch_or_baseline(self): - for field, value in (("release_head", BRANCH), ("baseline", "v1.4.0"), ("workflow_id", 90)): - with self.subTest(field=field): - self.api = ControllerAPI() - self.controller = coordinator.Controller(self.api, "test-actor") - _, _, plan = self.verification_reassessment() - plan["snapshots"][CORE][field] = value - plan["plan_id"] = coordinator.digest({key: value for key, value in plan.items() if key != "plan_id"}) - with self.assertRaisesRegex(ValueError, "cannot change the public candidate"): - self.recover_verification(plan) - - def test_public_recovery_refuses_bad_signature_assets_and_stale_automation(self): - for failure in ("signature", "assets", "stale"): - with self.subTest(failure=failure): - self.api = ControllerAPI() - self.controller = coordinator.Controller(self.api, "test-actor") - _, _, plan = self.verification_reassessment() - if failure == "stale": - self.api.changed[(CORE, "main")] = "f" * 40 - target = "assert_tag" if failure == "signature" else "check_public_assets" - original = self.api.optional - with patch.object(self.api, "optional", side_effect=lambda path: {"object": {"sha": MAIN}} if path.startswith(f"repos/{CORE}/git/ref/tags/") else original(path)), \ - patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"), \ - patch.object(release, target, side_effect=ValueError("Gate rejected")): - writes = len(self.api.writes) - with self.assertRaisesRegex(ValueError, "stale|Gate rejected"): - self.controller.approve(plan, 23, replan=True, recover_public_verification=True) - self.assertEqual(len(self.api.writes), writes) - - def test_public_recovery_rebinds_verification_without_git_or_new_publication(self): - _, _, plan = self.verification_reassessment() - issue, state = self.recover_verification(plan) - candidate = copy.deepcopy(release.Cycle(self.api, CORE, MONTH, "rehearse").state) - issue, state = self.controller.advance(issue, state) - environment = { - "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", "GITHUB_REF": "refs/heads/main", - "GITHUB_SHA": "e" * 40, "MONTHLY_PATCH_CONTROLLER_ISSUE": str(issue["number"]), - "MONTHLY_PATCH_CONTROLLER_PLAN": state["plan"]["plan_id"], "MONTHLY_PATCH_CONTROLLER_ATTEMPT": "1", - "MONTHLY_PATCH_REHEARSAL_ENABLED": "true", "MONTHLY_PATCH_TOKEN_READY": "true", - "MONTHLY_PATCH_SIGNING_KEY": "test-key", "MONTHLY_PATCH_SIGNER_EMAIL": "test@example.com", - "MONTHLY_PATCH_SIGNER_LOGIN": "test-actor", - } - with patch.dict(os.environ, environment), patch.object(release, "assert_tag"), \ - patch.object(release, "check_public_assets"), patch.object(release, "git") as git: - result = release.prepare(self.api, CORE, "rehearse", MONTH, pathlib.Path("."), pathlib.Path("."), True) - self.assertEqual(result["action"], "verify") - self.assertEqual(result["controller_plan_id"], state["plan"]["plan_id"]) - for field in ("tag", "commit", "assets"): - self.assertEqual(result[field], candidate[field]) - git.assert_not_called() - for field, value in (("status", "ready"), ("commit", BRANCH), ("assets", [{"name": "unexpected"}])): - with self.subTest(field=field), self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, CORE, MONTH, {**result, field: value}) - coordinator.worker_authorization(self.api, CORE, MONTH, result) - with self.assertRaisesRegex(ValueError, "cannot build or republish"): - release.publish(self.api, result, pathlib.Path(".")) - cycle = release.Cycle(self.api, CORE, MONTH, "rehearse") - cycle.state["status"] = "published" - cycle.save(cycle.state) - coordinator.worker_authorization(self.api, CORE, MONTH, notification=True) - with self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, CORE, MONTH) - with self.assertRaisesRegex(ValueError, "cannot authorize writing"): - coordinator.worker_authorization(self.api, CORE, MONTH, result, notification=True) - with tempfile.TemporaryDirectory() as output, patch.object(release, "GitHub", return_value=self.api), \ - patch("sys.argv", ["monthly_release.py", "notify-controller", "--repository", CORE, - "--mode", "rehearse", "--month", MONTH, "--output", output]): - self.assertEqual(release.main(), 0) - hints = [payload for path, method, payload in self.api.writes - if path == f"repos/{CLI}/dispatches" and method == "POST"] - self.assertEqual(len(hints), 1) - self.assertEqual(hints[0]["event_type"], "notation-release-progress") - cycle.state["status"] = "verifying" - cycle.save(cycle.state) - with self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, CORE, MONTH, candidate) - cycle.state["status"] = "merging" - cycle.save(cycle.state) - with self.assertRaisesRegex(ValueError, "cannot change or rebuild"): - coordinator.worker_authorization(self.api, CORE, MONTH) - self.assertFalse(any(method == "PUT" for _, method, _ in self.api.writes)) - - def test_unacknowledged_dispatch_is_not_blindly_reissued(self): - issue, state = self.approved() - original = self.api.request - with patch.object(self.api, "request", wraps=self.api.request) as request: - def interrupted(path, method="GET", payload=None): - if path.endswith("/dispatches"): - raise release.APIError("HTTP 503 ambiguous request") - return original(path, method, payload) - request.side_effect = interrupted - with self.assertRaises(release.APIError): - self.controller.advance(issue, state) - issue, restored = self.controller.records()[0] - self.assertEqual(restored["nodes"][CORE]["status"], "dispatching") - self.controller.advance(issue, restored) - self.assertEqual(self.api.dispatched, []) - - def test_one_immutable_plan_per_month(self): - issue, state = self.approved() - same_issue, same_state = self.controller.approve(state["plan"], 22) - self.assertEqual(same_issue["number"], issue["number"]) - self.assertEqual(same_state, state) - evidence = inventory((CLI,)) - changed = coordinator.validate_assessment(evidence, assessment(evidence, ("skip", "skip", "release"))) - with self.assertRaisesRegex(ValueError, "immutable"): - self.controller.approve(changed, 23) - - def test_overlapping_months_cannot_be_approved(self): - self.approved() - evidence = inventory((CORE,)) - evidence["month"] = "2026-11" - for snapshot in evidence["repositories"].values(): - snapshot["tag"] = "v1.3.4-monthly-test.202611" - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) - with self.assertRaisesRegex(ValueError, "active cycle"): - self.controller.approve(plan, 23) - - def test_stale_assessment_and_changed_refs_require_reassessment(self): - evidence = inventory((CORE,)) - evidence["collected_at"] = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=8)).isoformat() - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) - with self.assertRaisesRegex(ValueError, "stale"): - self.controller.approve(plan, 22) - self.api.changed[(CORE, "monthly-patch-test-main")] = PR_HEAD - with self.assertRaisesRegex(ValueError, "refs changed"): - self.approved() - - def test_changed_worker_code_is_not_dispatched(self): - issue, state = self.approved() - self.api.changed[(CORE, "main")] = PR_HEAD - with self.assertRaisesRegex(ValueError, "code changed"): - self.controller.advance(issue, state) - self.assertEqual(self.api.dispatched, []) - - def test_untrusted_issues_cannot_authorize_or_block_controller(self): - issue, state = self.approved() - impostor = copy.deepcopy(issue) - impostor.update(number=99, user={"login": "attacker"}, body=issue["body"].replace(MONTH, "2026-11")) - self.api.issues_by_repo[CLI].append(impostor) - self.assertEqual(len(self.controller.records()), 1) - self.assertIsNone(coordinator.decode_issue(impostor, "test-actor")) - - def test_duplicate_or_mutated_trusted_records_block(self): - issue, state = self.approved() - duplicated = {**issue, "number": 99} - self.api.issues_by_repo[CLI].append(duplicated) - with self.assertRaisesRegex(ValueError, "Duplicate"): - self.controller.records() - state["plan"]["snapshots"][CORE]["tag"] = "v1.3.99-monthly-test.202610" - with self.assertRaisesRegex(ValueError, "plan changed"): - coordinator.validate_state(state) - - def test_wrong_worker_actor_code_or_title_does_not_complete(self): - for field, value in (("actor", {"login": "attacker"}), ("head_sha", PR_HEAD), ("display_title", "unrelated")): - self.api = ControllerAPI() - self.controller = coordinator.Controller(self.api, "test-actor") - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - run = self.api.worker(CORE, issue, state) - run[field] = value - if field == "display_title": - self.controller.advance(issue, state) - self.assertEqual(state["nodes"][CORE]["status"], "dispatching") - else: - with self.subTest(field=field), self.assertRaises(ValueError): - self.controller.advance(issue, state) - self.assertEqual(len(self.api.dispatched), 1) - - def test_missing_cycle_or_unverified_assets_cannot_release_consumer(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state) - self.api.issues_by_repo[CORE] = [] - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["nodes"][CORE]["status"], "failed") - self.assertEqual(len(self.api.dispatched), 1) - - def test_asset_validation_failure_blocks_consumer(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state) - with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets", side_effect=ValueError("bad asset digest")), self.assertRaisesRegex(ValueError, "digest"): - self.controller.advance(issue, state) - self.assertEqual(len(self.api.dispatched), 1) - - def test_published_consumer_must_include_the_exact_planned_library(self): - issue, state = self.approved() - issue, state = self.controller.advance(issue, state) - self.api.worker(CORE, issue, state) - with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): - issue, state = self.controller.advance(issue, state) - self.api.worker(GO, issue, state) - self.api.manifests[(GO, MAIN, ".")]["Replace"][0]["New"]["Version"] = "v1.3.0" - with self.assertRaisesRegex(ValueError, "planned producer"): - self.controller.advance(issue, state) - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO]) - - -class WorkerAuthorizationTests(unittest.TestCase): - def setUp(self): - self.api = ControllerAPI() - self.environment = patch.dict(os.environ, { - "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", - "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": AUTOMATION, - }) - self.environment.start() - self.addCleanup(self.environment.stop) - evidence = inventory((CORE,)) - plan = coordinator.validate_assessment(evidence, assessment(evidence, ("release", "release", "release"))) - self.controller = coordinator.Controller(self.api, "test-actor") - self.issue, self.state = self.controller.approve(plan, 22) - self.issue, self.state = self.controller.advance(self.issue, self.state) - os.environ.update({ - "MONTHLY_PATCH_CONTROLLER_ISSUE": str(self.issue["number"]), - "MONTHLY_PATCH_CONTROLLER_PLAN": plan["plan_id"], "MONTHLY_PATCH_CONTROLLER_ATTEMPT": "1", - }) - - def test_only_current_dispatched_node_is_authorized(self): - authorization = coordinator.worker_authorization(self.api, CORE, MONTH) - self.assertEqual(authorization["issue"], self.issue["number"]) - with self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, GO, MONTH) - with self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, "notaryproject/notation-core-go", MONTH) - - def test_propagation_reassessment_checkpoint_excludes_receipts_already_in_the_base(self): - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - plan["merged"] = [{"number": 6, "commit": BRANCH}, {"number": 3, "commit": MAIN}] - snapshot = {"snapshot": {"main_head": BRANCH}} - with patch.object(release, "Cycle") as cycle, patch.object(coordinator, "worker_authorization"), \ - patch.object(coordinator, "compare_commits", return_value={MAIN}): - cycle.return_value.state = plan - self.assertEqual(propagation.source_head(self.api, plan, snapshot), MAIN) - - def test_propagation_checkpoint_still_rejects_missing_or_unapproved_post_checkpoint_merges(self): - plan = worker_plan(GO, "rehearse", MONTH, "waiting") - plan["merged"] = [{"number": 6, "commit": BRANCH}, {"number": 3, "commit": MAIN}, - {"number": 4, "commit": PR_HEAD}] - for commits in ({MAIN}, {MAIN, PR_HEAD, AUTOMATION}): - with self.subTest(commits=commits), patch.object(release, "Cycle") as cycle, \ - patch.object(coordinator, "worker_authorization"), \ - patch.object(coordinator, "compare_commits", return_value=commits), \ - self.assertRaisesRegex(ValueError, "outside the approved"): - cycle.return_value.state = plan - propagation.source_head(self.api, plan, {"snapshot": {"main_head": BRANCH}}) - - def test_missing_stale_or_changed_receipt_is_rejected(self): - for variable, value in (("MONTHLY_PATCH_CONTROLLER_ISSUE", ""), ("MONTHLY_PATCH_CONTROLLER_PLAN", "0" * 64), - ("MONTHLY_PATCH_CONTROLLER_ATTEMPT", "2"), ("GITHUB_SHA", PR_HEAD), - ("GITHUB_REF", "refs/heads/other")): - with patch.dict(os.environ, {variable: value}), self.subTest(variable=variable), self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, CORE, MONTH) - - def test_candidate_must_keep_fixed_tag_mode_and_producers(self): - candidate = worker_plan(CORE, "rehearse", MONTH) - candidate.update(controller_issue=self.issue["number"], controller_plan_id=self.state["plan"]["plan_id"], producers={}) - coordinator.worker_authorization(self.api, CORE, MONTH, candidate) - for field, value in (("tag", "v1.3.99-monthly-test.202610"), ("mode", "execute"), - ("cycle_id", "upstream-" + "1" * 64), ("producers", {"notation-core-go": {}})): - with self.subTest(field=field), self.assertRaises(ValueError): - coordinator.worker_authorization(self.api, CORE, MONTH, {**candidate, field: value}) - - def test_producer_versions_are_taken_from_verified_plan_not_latest_unplanned_release(self): - self.state["nodes"][CORE].update(status="published", version="v1.3.4-monthly-test.202610", commit=MAIN) - self.state["nodes"][GO].update(status="dispatching", attempt=1) - self.issue = self.controller.save(self.issue, self.state) - authorization = coordinator.worker_authorization(self.api, GO, MONTH) - self.assertEqual(authorization["producers"], {"notation-core-go": {"repository": CORE, "version": "v1.3.4-monthly-test.202610"}}) - - def test_assessed_pr_heads_cannot_change(self): - authorization = coordinator.worker_authorization(self.api, CORE, MONTH) - candidate = pull(sha=PR_HEAD) - self.assertTrue(coordinator.scoped_pull(self.api, CORE, candidate, authorization)) - candidate["head"]["sha"] = MAIN - with self.assertRaisesRegex(ValueError, "head changed"): - coordinator.scoped_pull(self.api, CORE, candidate, authorization) - - def test_new_unplanned_third_party_update_is_not_added(self): - authorization = coordinator.worker_authorization(self.api, CORE, MONTH) - self.assertFalse(coordinator.scoped_pull(self.api, CORE, pull(number=99), authorization)) - - def test_new_producer_pr_must_only_update_planned_producers(self): - candidate = pull(number=99, sha=PR_HEAD) - candidate["head"]["repo"]["full_name"] = GO - candidate["base"]["sha"] = MAIN - base = {"Go": "1.26", "Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.0"}], - "Replace": [{"Old": {"Path": "github.com/notaryproject/notation-core-go"}, - "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.3-monthly-test.202609"}}]} - after = copy.deepcopy(base) - after["Replace"][0]["New"]["Version"] = "v1.3.4-monthly-test.202610" - self.api.manifests = {(GO, MAIN, "."): base, (GO, PR_HEAD, "."): after} - self.api.manifests[(CORE, "v1.3.4-monthly-test.202610", ".")] = {"Go": "1.26"} - producers = {"notation-core-go": {"repository": CORE, "version": "v1.3.4-monthly-test.202610"}} - self.assertTrue(coordinator.producer_pull(self.api, GO, candidate, producers)) - after["Require"].append({"Path": "unrelated/module", "Version": "v2.0.0"}) - self.assertFalse(coordinator.producer_pull(self.api, GO, candidate, producers)) - after["Require"].pop() - after["Go"] = "1.27" - self.assertFalse(coordinator.producer_pull(self.api, GO, candidate, producers)) - - def test_propagation_allows_transitive_requirements_and_required_go_floor(self): - candidate = pull(number=99, sha=PR_HEAD) - candidate["head"]["repo"]["full_name"] = GO - candidate["base"]["sha"] = MAIN - module = "github.com/notaryproject/notation-core-go" - base = {"Go": "1.26", "Require": [{"Path": module, "Version": "v1.3.0"}], - "Replace": [{"Old": {"Path": module}, "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.3-monthly-test.202609"}}]} - after = copy.deepcopy(base) - after["Go"] = "1.26.2" - after["Replace"][0]["New"]["Version"] = "v1.3.4-monthly-test.202610" - after["Require"].append({"Path": "transitive/module", "Version": "v1.2.3", "Indirect": True}) - self.api.manifests = {(GO, MAIN, "."): base, (GO, PR_HEAD, "."): after, - (CORE, "v1.3.4-monthly-test.202610", "."): {"Go": "1.26.2"}} - producers = {"notation-core-go": {"repository": CORE, "version": "v1.3.4-monthly-test.202610"}} - self.assertTrue(coordinator.producer_pull(self.api, GO, candidate, producers)) - after["Toolchain"] = "go1.27.1" - with patch.object(release, "run", return_value="go1.27.1\n"): - self.assertTrue(coordinator.producer_pull(self.api, GO, candidate, producers)) - after["Go"] = "1.27" - self.assertFalse(coordinator.producer_pull(self.api, GO, candidate, producers)) - - def test_merged_propagation_keeps_its_observed_provenance_after_base_moves(self): - authorization = coordinator.worker_authorization(self.api, CORE, MONTH) - candidate = pull(number=99) - candidate.update(state="closed", merged_at="2026-10-04T00:00:00Z", merge_commit_sha=BRANCH) - self.assertTrue(coordinator.scoped_pull(self.api, CORE, candidate, authorization, - [{"number": 99, "commit": BRANCH}])) - candidate["merge_commit_sha"] = MAIN - with self.assertRaisesRegex(ValueError, "merge changed"): - coordinator.scoped_pull(self.api, CORE, candidate, authorization, [{"number": 99, "commit": BRANCH}]) - - def test_cli_can_merge_separate_core_and_go_propagation_prs(self): - candidate = pull(number=99, sha=PR_HEAD) - candidate["head"]["repo"]["full_name"] = CLI - candidate["base"]["sha"] = MAIN - base = { - "Go": "1.26", "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": "v1.3.0"} - for name in ("notation-core-go", "notation-go")], - "Replace": [{"Old": {"Path": f"github.com/notaryproject/{name}"}, - "New": {"Path": f"github.com/yizha1/{name}", "Version": "v1.3.3-monthly-test.202609"}} - for name in ("notation-core-go", "notation-go")], - } - after = copy.deepcopy(base) - after["Replace"][0]["New"]["Version"] = "v1.3.4-monthly-test.202610" - producers = {name: {"repository": f"yizha1/{name}", "version": "v1.3.4-monthly-test.202610"} - for name in ("notation-core-go", "notation-go")} - self.api.manifests = {(item["repository"], item["version"], "."): {"Go": "1.26"} for item in producers.values()} - for directory in release.modules(CLI): - self.api.manifests[(CLI, MAIN, directory)] = base - self.api.manifests[(CLI, PR_HEAD, directory)] = after - self.assertTrue(coordinator.producer_pull(self.api, CLI, candidate, producers)) - - def test_worker_policy_includes_authorization_before_merging(self): - with patch.object(release, "policy"), patch.object(release, "resume_cycle") as resume, patch.dict( - os.environ, {"MONTHLY_PATCH_CONTROLLER_PLAN": "0" * 64} - ), self.assertRaises(ValueError): - release.prepare(self.api, CORE, "rehearse", MONTH, pathlib.Path("."), pathlib.Path(".")) - resume.assert_not_called() - - def test_workflows_do_not_schedule_or_publish_consumers_independently(self): - root = pathlib.Path(__file__).parent.parent - worker = (root / "workflows/monthly-patch-release.yml").read_text() - controller = (root / "workflows/notation-release-controller.yml").read_text() if (root / "workflows/notation-release-controller.yml").exists() else "" - agent = (root / "workflows/notation-release-agent.md").read_text() if (root / "workflows/notation-release-agent.md").exists() else "" - self.assertNotIn(" schedule:", worker) - self.assertNotIn(" push:", worker) - self.assertNotIn(" pull_request:", worker) - self.assertIn(" workflow_dispatch:", worker) - self.assertIn(" push:", (root / "workflows/notation-release-validation.yml").read_text()) - self.assertNotIn("options: [dry-run, rehearse, execute]", worker) - self.assertIn("MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true'", worker) - self.assertIn("types: [notation-release-progress]", controller) if controller else None - if agent: - self.assertNotIn("MONTHLY_PATCH_SIGNING_KEY", agent) - self.assertNotIn("MONTHLY_PATCH_TOKEN", agent) - self.assertIn("report-failure-as-issue: false", agent) - - def test_manual_automation_files_keep_repository_license_headers(self): - root = pathlib.Path(__file__).parent.parent - header = "\n".join(pathlib.Path(release.__file__).read_text().splitlines()[:13]) - files = [root / "scripts/notation_release_controller.py", pathlib.Path(__file__), - root / "scripts/notation_fork_propagation.py", - root / "workflows/monthly-patch-release.yml", root / "workflows/notation-release-validation.yml"] - controller = root / "workflows/notation-release-controller.yml" - if controller.exists(): - files.append(controller) - for file in files: - with self.subTest(file=file): - self.assertTrue(file.read_text().startswith(header)) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/test_upstream_authorization.py b/.github/scripts/test_upstream_authorization.py deleted file mode 100644 index 5ae78352..00000000 --- a/.github/scripts/test_upstream_authorization.py +++ /dev/null @@ -1,380 +0,0 @@ -# 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 unittest -from unittest.mock import patch - -import monthly_release as release -import notation_release_controller as controller -from test_monthly_release import FakeGitHub, body, plan as candidate, pull - -CORE, GO, CLI = ("notaryproject/" + name for name in release.PROJECTS) -SOURCE, BRANCH, PR_HEAD, MERGED = (character * 40 for character in "abcd") -MONTH = "2026-10" -POLICY = { - "schema": 1, "repositories": [CORE, GO, CLI], - "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, - "release_votes": {"notation-core-go": "branch-rules", "notation-go": "repo-maintainer-majority", - "notation": "repo-maintainer-majority"}, -} -TREE = [{"path": controller.WORKER_PATH, "sha": "e" * 40, "type": "blob", "mode": "100644"}] -AUTOMATION = controller.digest({item["path"]: {key: item[key] for key in ("sha", "mode", "type")} for item in TREE}) - - -class UpstreamAPI(FakeGitHub): - def __init__(self): - super().__init__() - self.issues_by_repo = {repo: [] for repo in (CORE, GO, CLI)} - self.changed = {} - self.tree = copy.deepcopy(TREE) - self.dispatched = [] - self.runs = {} - self.policy = copy.deepcopy(POLICY) - self.comparison = [] - self.prs = {} - self.editor = "test-actor" - - def issue_editor(self, repository, number): - return self.editor - - def request(self, path, method="GET", payload=None): - repository = "/".join(path.split("/")[1:3]) - suffix = "/".join(path.split("/")[3:]) - if path == "user": - return {"login": "test-actor"} - if method == "POST" and suffix.endswith("/dispatches"): - self.dispatched.append((repository, payload)) - self.writes.append((path, method, payload)) - return None - if method != "GET": - return super().request(path, method, payload) - - if not suffix: - return {"fork": False, "private": False, "default_branch": "main", "has_issues": True} - if suffix.startswith("branches/"): - name = suffix.removeprefix("branches/") - return {"commit": {"sha": self.changed.get((repository, name), SOURCE if name == "main" else BRANCH)}} - if suffix.startswith("git/trees/"): - return {"truncated": False, "tree": self.tree} - if suffix.startswith("git/ref/tags/"): - return {"object": {"type": "tag", "sha": "e" * 40}} - if suffix.startswith("git/tags/"): - return {"object": {"sha": BRANCH}, "verification": {"verified": True}} - if suffix.startswith(f"contents/{controller.POLICY_PATH}"): - return {"encoding": "base64", "content": base64.b64encode(json.dumps(self.policy).encode()).decode()} - if suffix.startswith("issues/"): - return next(item for item in self.issues_by_repo[repository] if item["number"] == int(suffix.rsplit("/", 1)[1])) - if suffix == "actions/workflows/monthly-patch-release.yml": - return {"id": (CORE, GO, CLI).index(repository) + 10, "state": "active"} - if "/runs?" in suffix: - return {"workflow_runs": list(self.runs.get(repository, {}).values())} - if suffix.startswith("actions/runs/"): - return self.runs[repository][int(suffix.rsplit("/", 1)[1])] - if suffix.startswith("compare/"): - return {"status": "ahead", "ahead_by": len(self.comparison), "commits": [{"sha": sha} for sha in self.comparison]} - if suffix.startswith("pulls/"): - return self.prs[(repository, int(suffix.rsplit("/", 1)[1]))] - return super().request(path, method, payload) - - def worker(self, repository, issue, state, status="published", conclusion="success"): - node = state["nodes"][repository] - run_id = len(self.runs) + 100 - run = { - "id": run_id, "actor": {"login": "test-actor"}, "head_sha": node["dispatch_sha"], - "workflow_id": (CORE, GO, CLI).index(repository) + 10, - "event": "workflow_dispatch", "status": "completed", "conclusion": conclusion, - "display_title": controller.run_title(MONTH, state["plan"]["plan_id"], issue["number"], node["attempt"]), - } - self.runs.setdefault(repository, {})[run_id] = run - producers = { - name: {"repository": f"notaryproject/{name}", "version": state["nodes"][f"notaryproject/{name}"]["version"]} - for name in release.PROJECTS[repository.split("/")[1]] - if state["nodes"][f"notaryproject/{name}"]["status"] == "published" - } - worker = candidate(repository, "execute", MONTH, status) - worker.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], producers=producers, assets=[]) - worker["maintainer_roster_sha"] = state["plan"]["snapshots"][repository]["automation_sha"] - release.Cycle(self, repository, MONTH, "execute").save(worker) - for directory in release.modules(repository): - self.manifests[(repository, worker["commit"], directory)] = { - "Require": [{"Path": f"github.com/notaryproject/{name}", "Version": item["version"]} for name, item in producers.items()], - } - return run - - -def inventory(changes=(CORE,)): - return { - "schema": 1, "host": CLI, "month": MONTH, "collected_at": controller.utc_now(), - "repositories": { - repo: { - "baseline": "v1.3.0", "main": "main", "branch": "release-1.3", "tag": "v1.3.1", - "main_head": SOURCE, "release_head": BRANCH, "automation_sha": SOURCE, - "automation_digest": AUTOMATION, "workflow_id": index + 10, - "baseline_sha": BRANCH, "baseline_tag_object": "e" * 40, - "source_repository": repo, "source_commit": BRANCH, "blockers": [], - "pulls": [{"number": 1, "head": PR_HEAD}] if repo in changes else [], - "merged": [], "manifests": {directory: {} for directory in release.modules(repo)}, - "scans": {directory: {"reachable": [], "informational": [], "advisories": {}} for directory in release.modules(repo)}, - } - for index, repo in enumerate((CORE, GO, CLI)) - }, - } - - -def assessed(evidence, decisions=("release", "release", "release")): - assessment = { - "schema": 1, "month": MONTH, "inventory_sha256": controller.digest(evidence), - "decisions": { - repo: {"decision": decision, "reason": "Assessed update", - "evidence": [f"{repo}:dependencies"]} - for repo, decision in zip((CORE, GO, CLI), decisions) - }, - } - return controller.validate_assessment(evidence, assessment) - - -@unittest.skipIf(controller.FORK, "Canonical tests run in the canonical repository context") -class UpstreamAuthorizationTests(unittest.TestCase): - def setUp(self): - self.api = UpstreamAPI() - self.env = patch.dict(os.environ, { - "GITHUB_REPOSITORY": CLI, "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": SOURCE, - "MONTHLY_PATCH_ACTOR": "test-actor", "MONTHLY_PATCH_COORDINATOR_REQUIRED": "true", - "NOTATION_RELEASE_AUTO_APPROVAL_ENABLED": "true", - }) - self.env.start() - self.addCleanup(self.env.stop) - self.controller = controller.Controller(self.api, "test-actor") - - def dispatch(self): - issue, state = self.controller.approve(assessed(inventory()), 22) - issue, state = self.controller.advance(issue, state) - os.environ.update({ - "MONTHLY_PATCH_CONTROLLER_ISSUE": str(issue["number"]), - "MONTHLY_PATCH_CONTROLLER_PLAN": state["plan"]["plan_id"], - "MONTHLY_PATCH_CONTROLLER_ATTEMPT": "1", - }) - return issue, state - - def test_canonical_plan_and_dispatch_use_stable_versions_and_real_main(self): - issue, state = self.dispatch() - self.assertEqual(controller.HOST, CLI) - self.assertEqual(state["mode"], "execute") - self.assertEqual(self.api.dispatched[0][0], CORE) - self.assertEqual(self.api.dispatched[0][1]["inputs"]["mode"], "execute") - self.assertEqual(controller.worker_authorization(self.api, CORE, MONTH)["snapshot"]["tag"], "v1.3.1") - with self.assertRaises(ValueError): - controller.worker_authorization(self.api, "yizha1/notation-core-go", MONTH) - - def test_changed_automation_blocks_dispatch_and_worker(self): - issue, state = self.dispatch() - self.api.tree[0]["sha"] = "f" * 40 - with self.assertRaisesRegex(ValueError, "code changed"): - controller.worker_authorization(self.api, CORE, MONTH) - state["nodes"][CORE]["status"] = "pending" - with self.assertRaisesRegex(ValueError, "code changed"): - self.controller.advance(issue, state) - - def test_recorded_dependency_merge_does_not_invalidate_automation(self): - issue, state = self.dispatch() - worker = candidate(CORE, "execute", MONTH, "waiting") - worker.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], - merged=[{"number": 1, "commit": MERGED}], producers={}) - release.Cycle(self.api, CORE, MONTH, "execute").save(worker) - self.api.changed[(CORE, "main")] = MERGED - self.api.comparison = [MERGED] - merged = pull(sha=PR_HEAD) - merged.update(merged_at="2026-10-01T00:00:00Z", merge_commit_sha=MERGED, state="closed") - self.api.prs[(CORE, 1)] = merged - self.assertEqual(controller.source_checkpoint(self.api, CORE, state["plan"]["snapshots"][CORE], MONTH), MERGED) - controller.worker_authorization(self.api, CORE, MONTH) - self.api.comparison.append("f" * 40) - with self.assertRaisesRegex(ValueError, "outside recorded"): - controller.worker_authorization(self.api, CORE, MONTH) - - def test_unrecorded_main_changes_require_reassessment(self): - issue, state = self.dispatch() - self.api.changed[(CORE, "main")] = MERGED - with self.assertRaisesRegex(ValueError, "trusted worker"): - controller.worker_authorization(self.api, CORE, MONTH) - - def test_policy_is_disabled_by_default_and_exactly_scoped(self): - with patch.dict(os.environ, {"NOTATION_RELEASE_AUTO_APPROVAL_ENABLED": ""}), self.assertRaisesRegex(ValueError, "disabled"): - controller.authorize_routine(self.api, assessed(inventory())) - self.api.policy["optional_backports"] = "automatic" - with self.assertRaisesRegex(ValueError, "Unsupported"): - controller.authorize_routine(self.api, assessed(inventory())) - - def test_automatic_authorization_checks_semantics_not_agent_rationale(self): - update = pull(sha=PR_HEAD) - update["base"]["sha"] = SOURCE - self.api.prs[(CORE, 1)] = update - before = {"Go": "1.24", "Require": [{"Path": "example/dependency", "Version": "v1.2.0"}]} - after = copy.deepcopy(before) - after["Require"][0]["Version"] = "v1.2.1" - self.api.manifests[(CORE, SOURCE, ".")] = before - self.api.manifests[(CORE, PR_HEAD, ".")] = after - receipt = controller.authorize_routine(self.api, assessed(inventory())) - self.assertEqual(receipt["kind"], "policy") - for version in ("v2.0.0", "v1.3.0", "v1.1.9", "v1.2.1-rc.1"): - after["Require"][0]["Version"] = version - with self.subTest(version=version), self.assertRaises(ValueError): - controller.authorize_routine(self.api, assessed(inventory())) - - def test_reachable_findings_and_workflow_changes_need_explicit_resolution(self): - evidence = inventory() - evidence["repositories"][CORE]["scans"]["."]["reachable"] = ["GO-2026-1234"] - with self.assertRaisesRegex(ValueError, "Reachable"): - controller.authorize_routine(self.api, assessed(evidence)) - update = pull(sha=PR_HEAD) - update["base"]["sha"] = SOURCE - self.api.prs[(CORE, 1)] = update - self.api.files = [{"filename": ".github/workflows/build.yml"}] - with self.assertRaisesRegex(ValueError, "explicit actor"): - controller.authorize_routine(self.api, assessed(inventory())) - - def test_exceptional_authorization_requires_manual_configured_actor(self): - for event, actor in (("schedule", "test-actor"), ("workflow_dispatch", "attacker")): - with patch.dict(os.environ, {"GITHUB_EVENT_NAME": event, "GITHUB_ACTOR": actor}), self.assertRaises(ValueError): - controller.explicit_actor() - with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_ACTOR": "test-actor"}): - controller.explicit_actor() - - def test_late_producer_updates_require_exact_canonical_versions(self): - update = pull(number=99, sha=PR_HEAD) - update["head"]["repo"]["full_name"] = GO - update["base"]["sha"] = SOURCE - before = {"Go": "1.24", "Require": [{"Path": "github.com/notaryproject/notation-core-go", "Version": "v1.3.0"}]} - after = copy.deepcopy(before) - after["Require"][0]["Version"] = "v1.3.1" - self.api.manifests = { - (GO, SOURCE, "."): before, (GO, PR_HEAD, "."): after, - (CORE, "v1.3.1", "."): {"Go": "1.24"}, - } - producers = {"notation-core-go": {"repository": CORE, "version": "v1.3.1"}} - with patch.object(release, "run", return_value="go1.27.1"): - self.assertTrue(controller.producer_pull(self.api, GO, update, producers)) - after["Require"][0]["Version"] = "v1.3.2" - self.assertFalse(controller.producer_pull(self.api, GO, update, producers)) - after["Require"][0]["Version"] = "v1.3.1" - after["Replace"] = [{"Old": {"Path": "github.com/notaryproject/notation-core-go"}, - "New": {"Path": "github.com/yizha1/notation-core-go", "Version": "v1.3.1"}}] - with self.assertRaises(ValueError): - controller.producer_pull(self.api, GO, update, producers) - - def test_source_and_packaging_jobs_receive_no_release_secrets(self): - workflow = (pathlib.Path(__file__).parent.parent / "workflows/monthly-patch-release.yml").read_text() - for name, next_name in (("qualify", "package"), ("package", "publish"), ("verify", "complete")): - section = workflow.split(f" {name}:\n", 1)[1].split(f" {next_name}:\n", 1)[0] - self.assertNotIn("secrets.", section) - publisher = workflow.split(" publish:\n", 1)[1].split(" verify:\n", 1)[0] - self.assertNotIn("build_monthly_assets.py", publisher) - self.assertNotIn("go test", publisher) - callback = workflow.split(" notify-controller:\n", 1)[1] - self.assertIn("inputs.mode == 'execute'", callback) - self.assertIn("github.actor == vars.MONTHLY_PATCH_ACTOR", callback) - self.assertIn("github.ref == 'refs/heads/main'", callback) - - def test_worker_roster_pin_must_match_assessed_main(self): - issue, state = self.dispatch() - worker = candidate(CORE, "execute", MONTH, "waiting") - worker.update(controller_issue=issue["number"], controller_plan_id=state["plan"]["plan_id"], producers={}) - with self.assertRaisesRegex(ValueError, "roster"): - controller.worker_authorization(self.api, CORE, MONTH, worker) - worker["maintainer_roster_sha"] = SOURCE - controller.worker_authorization(self.api, CORE, MONTH, worker) - worker["maintainer_roster_sha"] = BRANCH - with self.assertRaisesRegex(ValueError, "roster"): - controller.worker_authorization(self.api, CORE, MONTH, worker) - - def test_public_recovery_preserves_distinct_voted_commit_and_merge_tip(self): - issue, previous = self.controller.approve(assessed(inventory()), 22) - worker = candidate(CORE, "execute", MONTH, "verifying") - worker.update(commit=PR_HEAD, previous_head=MERGED, release_backport={"number": 7}, - controller_issue=issue["number"], controller_plan_id=previous["plan"]["plan_id"], assets=[]) - release.Cycle(self.api, CORE, MONTH, "execute").save(worker) - evidence = inventory() - evidence["repositories"][CORE].update(main_head="f" * 40, automation_sha="f" * 40, release_head=MERGED) - self.api.changed[(CORE, "main")] = "f" * 40 - self.api.changed[(CORE, "release-1.3")] = MERGED - replacement = assessed(evidence) - optional = self.api.optional - self.api.changed[(CORE, "release-1.3")] = SOURCE - with patch.object(self.api, "optional", side_effect=lambda path: {"object": {"sha": PR_HEAD}} if path == f"repos/{CORE}/git/ref/tags/v1.3.1" else optional(path)), \ - patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"), \ - self.assertRaisesRegex(ValueError, "stale"): - self.controller.approve(replacement, 23, replan=True, recover_public_verification=True) - self.api.changed[(CORE, "release-1.3")] = MERGED - with patch.object(self.api, "optional", side_effect=lambda path: {"object": {"sha": PR_HEAD}} if path == f"repos/{CORE}/git/ref/tags/v1.3.1" else optional(path)), \ - patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): - issue, state = self.controller.approve(replacement, 23, replan=True, recover_public_verification=True) - self.assertEqual(state["plan"]["snapshots"][CORE]["main_head"], "f" * 40) - self.assertEqual(release.Cycle(self.api, CORE, MONTH, "execute").state["commit"], PR_HEAD) - self.assertEqual(release.Cycle(self.api, CORE, MONTH, "execute").state["previous_head"], MERGED) - def test_canonical_core_go_cli_order_and_verified_producer_authority(self): - issue, state = self.dispatch() - core_run = self.api.worker(CORE, issue, state) - with patch.object(release, "assert_tag"), patch.object(release, "check_public_assets"): - issue, state = self.controller.advance(issue, state) - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO]) - authorization = controller.worker_authorization(self.api, GO, MONTH) - self.assertEqual(authorization["producers"], {"notation-core-go": {"repository": CORE, "version": "v1.3.1"}}) - core_run["actor"]["login"] = "forged" - with self.assertRaisesRegex(ValueError, "trusted successful"): - controller.worker_authorization(self.api, GO, MONTH) - core_run["actor"]["login"] = "test-actor" - self.api.worker(GO, issue, state) - issue, state = self.controller.advance(issue, state) - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE, GO, CLI]) - self.api.worker(CLI, issue, state) - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["status"], "completed") - - def test_verification_record_alone_cannot_advance_before_worker_run_success(self): - issue, state = self.dispatch() - run = self.api.worker(CORE, issue, state) - run["status"] = "in_progress" - issue, state = self.controller.advance(issue, state) - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE]) - run.update(status="completed", conclusion="failure") - issue, state = self.controller.advance(issue, state) - self.assertEqual(state["nodes"][CORE]["status"], "failed") - self.assertEqual([repo for repo, _ in self.api.dispatched], [CORE]) - - def test_signed_baseline_and_coordinator_receipt_cannot_be_bypassed(self): - self.dispatch() - with patch.dict(os.environ, {"MONTHLY_PATCH_COORDINATOR_REQUIRED": ""}), self.assertRaisesRegex(ValueError, "always require"): - controller.worker_authorization(self.api, CORE, MONTH) - with patch.object(release, "verified_tag", return_value="f" * 40), self.assertRaisesRegex(ValueError, "baseline"): - controller.worker_authorization(self.api, CORE, MONTH) - - def test_issue_author_alone_cannot_authorize_a_forged_state_edit(self): - self.dispatch() - self.api.editor = "attacker" - with self.assertRaisesRegex(ValueError, "edited outside"): - controller.worker_authorization(self.api, CORE, MONTH) - with self.assertRaisesRegex(ValueError, "edited outside"): - self.controller.records() - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/monthly-patch-release.yml b/.github/workflows/monthly-patch-release.yml index 3fccd7c6..a70f3bb0 100644 --- a/.github/workflows/monthly-patch-release.yml +++ b/.github/workflows/monthly-patch-release.yml @@ -10,7 +10,10 @@ # 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. - +# +# This draft pin cannot authorize execution. Replace both SHA values with the +# final upstream merged CLI implementation SHA before merging this caller or +# enabling monthly writes. name: Monthly dependency patch release run-name: 'Monthly patch ${{ inputs.month }} ${{ inputs.plan_id }} #${{ inputs.controller_issue }} attempt ${{ inputs.attempt }}' @@ -41,473 +44,21 @@ on: required: false permissions: + actions: read contents: read issues: read pull-requests: read -concurrency: - group: monthly-dependency-patch-${{ github.repository }} - cancel-in-progress: false - -env: - GOWORK: 'off' - GOTOOLCHAIN: local - MONTHLY_PATCH_COORDINATOR_REQUIRED: 'true' - MONTHLY_PATCH_CONTROLLER_ISSUE: ${{ inputs.controller_issue }} - MONTHLY_PATCH_CONTROLLER_PLAN: ${{ inputs.plan_id }} - MONTHLY_PATCH_CONTROLLER_ATTEMPT: ${{ inputs.attempt }} - jobs: - prepare: - if: >- - contains(fromJSON('["notaryproject/notation-core-go","notaryproject/notation-go","notaryproject/notation"]'), github.repository) && - github.ref == 'refs/heads/main' && - github.event_name == 'workflow_dispatch' && - (inputs.mode == 'dry-run' || github.actor == vars.MONTHLY_PATCH_ACTOR) - runs-on: ubuntu-24.04 - outputs: - action: ${{ steps.cycle.outputs.action }} - commit: ${{ steps.cycle.outputs.commit }} - tag: ${{ steps.cycle.outputs.tag }} - baseline: ${{ steps.cycle.outputs.baseline }} - minimum_go: ${{ steps.cycle.outputs.minimum_go }} - mode: ${{ steps.cycle.outputs.mode }} - month: ${{ steps.cycle.outputs.month }} - cycle_id: ${{ steps.cycle.outputs.cycle_id }} - status: ${{ steps.cycle.outputs.status }} - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: stable - check-latest: true - - name: Test orchestration before using repository credentials - env: - MONTHLY_PATCH_COORDINATOR_REQUIRED: 'false' - run: | - GITHUB_REPOSITORY=yizha1/notation python3 -m unittest discover -s .github/scripts -p 'test_*release*.py' - python3 -m unittest discover -s .github/scripts -p 'test_upstream_authorization.py' - - name: Merge checked Dependabot PRs and prepare an unpushed release candidate - id: cycle - env: - GH_TOKEN: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN || github.token }} - MONTHLY_PATCH_TOKEN_READY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN != '' }} - MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} - MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} - MONTHLY_PATCH_SIGNING_KEY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY || '' }} - MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} - MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} - MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - MODE: ${{ inputs.mode }} - REQUESTED_MONTH: ${{ inputs.month }} - run: | - args=(--start) - if [[ -n "$REQUESTED_MONTH" ]]; then args+=(--month "$REQUESTED_MONTH"); fi - python3 .github/scripts/monthly_release.py prepare \ - --mode "$MODE" --output "$RUNNER_TEMP/monthly-candidate" "${args[@]}" - - name: Retain cycle plan and candidate bundle - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-candidate - path: | - ${{ runner.temp }}/monthly-candidate/plan.json - ${{ runner.temp }}/monthly-candidate/candidate.bundle - if-no-files-found: warn - - qualify: - needs: prepare - if: needs.prepare.outputs.action == 'qualify' - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - go-version: ['${{ needs.prepare.outputs.minimum_go }}', stable] - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - path: automation - persist-credentials: false - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - path: candidate - fetch-depth: 0 - persist-credentials: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: monthly-candidate - path: ${{ runner.temp }}/monthly-candidate - - name: Import the exact unpushed candidate - run: | - python3 automation/.github/scripts/monthly_release.py import \ - --directory candidate --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ - --bundle "$RUNNER_TEMP/monthly-candidate/candidate.bundle" \ - --output "$RUNNER_TEMP/imported" - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - cache-dependency-path: 'candidate/**/go.sum' - - name: Install pinned qualification tools - run: | - go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - go install github.com/apache/skywalking-eyes/cmd/license-eye@v0.9.0 - - name: Qualify modules, tests, licensing and vulnerabilities - env: - MONTHLY_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - MONTHLY_PATCH_ADVISORY_DISPOSITIONS: ${{ vars.MONTHLY_PATCH_ADVISORY_DISPOSITIONS }} - run: | - python3 automation/.github/scripts/qualify_monthly_candidate.py candidate \ - --repository "$GITHUB_REPOSITORY" --evidence "$RUNNER_TEMP/source-evidence" - - name: Retain race coverage, tests and scan results - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-source-${{ matrix.go-version }} - path: ${{ runner.temp }}/source-evidence/ - if-no-files-found: warn - - package: - needs: [prepare, qualify] - if: >- - always() && needs.prepare.result == 'success' && - ((needs.prepare.outputs.action == 'qualify' && needs.qualify.result == 'success') || - needs.prepare.outputs.action == 'package') - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - path: automation - persist-credentials: false - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - path: candidate - fetch-depth: 0 - persist-credentials: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: monthly-candidate - path: ${{ runner.temp }}/monthly-candidate - - name: Import or check out the exact qualified commit - env: - ACTION: ${{ needs.prepare.outputs.action }} - COMMIT: ${{ needs.prepare.outputs.commit }} - run: | - if [[ "$ACTION" == qualify ]]; then - python3 automation/.github/scripts/monthly_release.py import \ - --directory candidate --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ - --bundle "$RUNNER_TEMP/monthly-candidate/candidate.bundle" \ - --output "$RUNNER_TEMP/imported" - else - git -C candidate checkout --detach "$COMMIT" - fi - - name: Stage an unpushed local tag for packaging without any signing credentials - if: needs.prepare.outputs.action == 'qualify' - env: - TAG: ${{ needs.prepare.outputs.tag }} - COMMIT: ${{ needs.prepare.outputs.commit }} - run: | - git -C candidate -c user.name=Packaging -c user.email=packaging@example.invalid \ - -c tag.gpgsign=false tag -a "$TAG" "$COMMIT" -m "Unpublished packaging tag" - - name: Fetch the immutable tag without overwriting it - if: needs.prepare.outputs.action == 'package' - env: - TAG: ${{ needs.prepare.outputs.tag }} - run: | - git -C candidate fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: stable - check-latest: true - - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 - if: github.event.repository.name == 'notation' - env: - GORELEASER_PREVIOUS_TAG: ${{ needs.prepare.outputs.baseline }} - with: - workdir: candidate - distribution: goreleaser - version: v2.18.2 - args: release --clean --skip=publish --config ../automation/.github/monthly-goreleaser.yml - - name: Build library source archives or validate every CLI archive - env: - TAG: ${{ needs.prepare.outputs.tag }} - COMMIT: ${{ needs.prepare.outputs.commit }} - MONTHLY_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - MONTHLY_PATCH_ADVISORY_DISPOSITIONS: ${{ vars.MONTHLY_PATCH_ADVISORY_DISPOSITIONS }} - run: | - go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - python3 automation/.github/scripts/build_monthly_assets.py candidate \ - --repository "$GITHUB_REPOSITORY" --tag "$TAG" --commit "$COMMIT" \ - --evidence "$RUNNER_TEMP/build-evidence" - - name: Retain qualified assets without publishing - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-built-assets - path: candidate/dist/* - if-no-files-found: error - - name: Retain binary gate results - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-build-evidence - path: ${{ runner.temp }}/build-evidence/ - if-no-files-found: warn - - publish: - needs: [prepare, qualify, package] - if: >- - always() && needs.prepare.result == 'success' && needs.package.result == 'success' && - github.ref == 'refs/heads/main' && github.actor == vars.MONTHLY_PATCH_ACTOR && - ((needs.prepare.outputs.action == 'qualify' && needs.qualify.result == 'success') || - needs.prepare.outputs.action == 'package') - runs-on: ubuntu-24.04 - steps: - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: stable - check-latest: true - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - path: automation - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - path: candidate - fetch-depth: 0 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: monthly-candidate - path: ${{ runner.temp }}/monthly-candidate - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: monthly-built-assets - path: ${{ runner.temp }}/monthly-assets - - name: Import immutable candidate without running source or build commands - env: - ACTION: ${{ needs.prepare.outputs.action }} - COMMIT: ${{ needs.prepare.outputs.commit }} - run: | - if [[ "$ACTION" == qualify ]]; then - python3 automation/.github/scripts/monthly_release.py import --directory candidate \ - --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ - --bundle "$RUNNER_TEMP/monthly-candidate/candidate.bundle" \ - --output "$RUNNER_TEMP/imported" - else - git -C candidate checkout --detach "$COMMIT" - fi - - name: Stage the exact signed tag in the isolated publisher - if: needs.prepare.outputs.action == 'qualify' - env: - GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} - MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} - MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} - MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} - MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} - MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} - MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - run: | - python3 automation/.github/scripts/monthly_release.py stage-tag --directory candidate \ - --plan "$RUNNER_TEMP/monthly-candidate/plan.json" --output "$RUNNER_TEMP/staged" - - name: Atomically publish the branch and signed tag after every asset gate - if: needs.prepare.outputs.action == 'qualify' - env: - GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} - MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} - MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} - MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} - MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} - MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} - MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} - MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - run: | - python3 automation/.github/scripts/monthly_release.py tag --directory candidate \ - --plan "$RUNNER_TEMP/monthly-candidate/plan.json" \ - --output "$RUNNER_TEMP/tagged" - - name: Publish only qualified assets - id: release - env: - GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} - MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} - MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} - MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} - MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} - MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} - MONTHLY_PATCH_SIGNER_NAME: ${{ vars.MONTHLY_PATCH_SIGNER_NAME }} - MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - ACTION: ${{ needs.prepare.outputs.action }} - run: | - plan="$RUNNER_TEMP/monthly-candidate/plan.json" - if [[ "$ACTION" == qualify ]]; then plan="$RUNNER_TEMP/tagged/plan.json"; fi - python3 automation/.github/scripts/monthly_release.py publish \ - --directory "$RUNNER_TEMP/monthly-assets" --plan "$plan" --output "$RUNNER_TEMP/published" - - name: Retain publication manifest and binary gate results - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-publication - path: | - ${{ runner.temp }}/published/ - if-no-files-found: warn - - verify: - needs: [prepare, publish] - if: >- - always() && needs.prepare.result == 'success' && - (needs.publish.result == 'success' || needs.prepare.outputs.action == 'verify') - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04 - platform: linux_amd64 - - os: macos-14 - platform: darwin_arm64 - - os: windows-latest - platform: windows_amd64 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - path: automation - persist-credentials: false - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - path: candidate - ref: ${{ needs.prepare.outputs.commit }} - persist-credentials: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: needs.publish.result == 'success' - with: - name: monthly-publication - path: ${{ runner.temp }}/publication - - name: Locate publication manifest - if: needs.publish.result == 'success' - shell: bash - run: cp "$RUNNER_TEMP/publication/published/plan.json" "$RUNNER_TEMP/publication/plan.json" - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: needs.prepare.outputs.action == 'verify' - with: - name: monthly-candidate - path: ${{ runner.temp }}/publication - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: stable - check-latest: true - - name: Test the downloaded public release, never a rebuilt CLI - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PLATFORM: ${{ matrix.platform }} - MONTHLY_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - MONTHLY_PATCH_ADVISORY_DISPOSITIONS: ${{ vars.MONTHLY_PATCH_ADVISORY_DISPOSITIONS }} - run: | - go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - python3 automation/.github/scripts/test_monthly_public_release.py \ - --plan "$RUNNER_TEMP/publication/plan.json" --source candidate \ - --platform "$PLATFORM" --evidence "$RUNNER_TEMP/public-evidence" - - name: Retain native functional tests and packaged-binary E2E evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-public-${{ matrix.platform }} - path: ${{ runner.temp }}/public-evidence/ - if-no-files-found: warn - - complete: - needs: [prepare, publish, verify] - if: >- - always() && needs.verify.result == 'success' && - (needs.publish.result == 'success' || needs.prepare.outputs.action == 'verify') - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: needs.publish.result == 'success' - with: - name: monthly-publication - path: ${{ runner.temp }}/publication - - name: Locate publication manifest - if: needs.publish.result == 'success' - run: cp "$RUNNER_TEMP/publication/published/plan.json" "$RUNNER_TEMP/publication/plan.json" - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: needs.prepare.outputs.action == 'verify' - with: - name: monthly-candidate - path: ${{ runner.temp }}/publication - - name: Complete the producer cycle only after public package verification - env: - GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} - MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} - MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} - MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} - MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} - MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} - MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - run: | - python3 .github/scripts/monthly_release.py complete \ - --plan "$RUNNER_TEMP/publication/plan.json" --output "$RUNNER_TEMP/completed" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: monthly-completed - path: ${{ runner.temp }}/completed/plan.json - if-no-files-found: error - - record-failure: - needs: [prepare, qualify, package, publish, verify, complete] - if: >- - always() && contains(needs.*.result, 'failure') && - needs.prepare.outputs.mode != '' && needs.prepare.outputs.mode != 'dry-run' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - name: Keep consumers blocked and record the failure for the next retry - env: - GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} - MONTHLY_PATCH_TOKEN_READY: ${{ secrets.MONTHLY_PATCH_TOKEN != '' }} - MONTHLY_PATCH_ENABLED: ${{ vars.MONTHLY_PATCH_ENABLED }} - MONTHLY_PATCH_REHEARSAL_ENABLED: ${{ vars.MONTHLY_PATCH_REHEARSAL_ENABLED }} - MONTHLY_PATCH_SIGNING_KEY: ${{ secrets.MONTHLY_PATCH_SIGNING_KEY }} - MONTHLY_PATCH_SIGNER_EMAIL: ${{ vars.MONTHLY_PATCH_SIGNER_EMAIL }} - MONTHLY_PATCH_SIGNER_LOGIN: ${{ vars.MONTHLY_PATCH_SIGNER_LOGIN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - MODE: ${{ needs.prepare.outputs.mode }} - MONTH: ${{ needs.prepare.outputs.month }} - CYCLE_ID: ${{ needs.prepare.outputs.cycle_id }} - FAILURE_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - python3 .github/scripts/monthly_release.py failure \ - --mode "$MODE" --month "$MONTH" --cycle-id "$CYCLE_ID" --output "$RUNNER_TEMP/failure" \ - --reason "A workflow gate failed. Inspect $FAILURE_URL and its retained evidence before retrying." - - notify-controller: - needs: [prepare, qualify, package, publish, verify, complete, record-failure] - if: >- - always() && needs.prepare.result == 'success' && needs.prepare.outputs.mode == 'execute' && - inputs.mode == 'execute' && github.ref == 'refs/heads/main' && - github.actor == vars.MONTHLY_PATCH_ACTOR - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - name: Wake the controller, never publish a consumer directly - env: - GH_TOKEN: ${{ secrets.MONTHLY_PATCH_TOKEN }} - MONTHLY_PATCH_ACTOR: ${{ vars.MONTHLY_PATCH_ACTOR }} - MONTH: ${{ needs.prepare.outputs.month }} - run: | - python3 .github/scripts/monthly_release.py notify-controller \ - --mode execute --month "$MONTH" --output "$RUNNER_TEMP/notified" + release: + uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@16fcf79278a01b403233170611f94eb665bdde07 + with: + mode: ${{ inputs.mode }} + month: ${{ inputs.month }} + controller_issue: ${{ inputs.controller_issue }} + plan_id: ${{ inputs.plan_id }} + attempt: ${{ inputs.attempt }} + worker_sha: 16fcf79278a01b403233170611f94eb665bdde07 + secrets: + MONTHLY_PATCH_TOKEN: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN || '' }} + MONTHLY_PATCH_SIGNING_KEY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY || '' }} diff --git a/.github/workflows/notation-release-validation.yml b/.github/workflows/notation-release-validation.yml deleted file mode 100644 index 507942d9..00000000 --- a/.github/workflows/notation-release-validation.yml +++ /dev/null @@ -1,95 +0,0 @@ -# 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. - -name: Notation release automation checks - -on: - push: - branches: [main] - paths: - - '.github/scripts/**' - - '.github/workflows/monthly-patch-release.yml' - - '.github/monthly-patch-policy.yml' - - '.github/workflows/notation-release-*' - - '.github/licenserc.yml' - - '.gitattributes' - pull_request: - paths: - - '.github/scripts/**' - - '.github/workflows/monthly-patch-release.yml' - - '.github/monthly-patch-policy.yml' - - '.github/workflows/notation-release-*' - - '.github/licenserc.yml' - - '.gitattributes' - workflow_dispatch: - -permissions: - contents: read - actions: read - issues: read - pull-requests: read - -jobs: - validate: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - env: - GOWORK: 'off' - GOTOOLCHAIN: local - PYTHONDONTWRITEBYTECODE: '1' - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: stable - check-latest: true - - name: Test release controller and worker regressions - run: | - GITHUB_REPOSITORY=yizha1/notation python3 -m unittest discover -s .github/scripts -p 'test_*release*.py' - python3 -m unittest discover -s .github/scripts -p 'test_upstream_authorization.py' - - name: Lint the worker and validation workflow - run: | - go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 - actionlint .github/workflows/monthly-patch-release.yml \ - .github/workflows/notation-release-validation.yml - - name: Validate the compiled assessment workflow when present - env: - GH_TOKEN: ${{ github.token }} - run: | - if [[ -f .github/workflows/notation-release-agent.md ]]; then - gh extension install github/gh-aw --pin v0.89.21 - gh aw compile notation-release-agent --strict --no-emit \ - --gh-aw-ref c35393777e5604a63721d09512263b1383301d4f --no-check-update - actionlint .github/workflows/notation-release-controller.yml - # actionlint does not yet recognize gh-aw's generated job-level queue. - actionlint -ignore '^unexpected key "queue" for "concurrency" section\.' \ - .github/workflows/notation-release-agent.lock.yml - gh aw list --json | jq -e 'all(.[]; .compiled == "Yes")' - fi - - name: Collect canonical evidence without AI or release credentials - if: github.repository == 'notaryproject/notation' && github.event_name == 'push' - env: - GH_TOKEN: ${{ github.token }} - run: | - go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - python3 .github/scripts/notation_release_controller.py collect \ - --output "$RUNNER_TEMP/notation-release-evidence" - - name: Retain assessment evidence, including explicit setup blockers - if: always() && github.repository == 'notaryproject/notation' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: notation-release-evidence - path: ${{ runner.temp }}/notation-release-evidence/ - if-no-files-found: warn From 8ce92e2d02f967da79b62e2fa02d7236eefbd6d4 Mon Sep 17 00:00:00 2001 From: Yi Zha <107919912+yizha1@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:11:36 -0700 Subject: [PATCH 3/5] feat(release): require plan approval and manual publication Pin the centralized manual-first worker and document exact plan approval, qualified draft review, maintainer publication, and immutable anonymous public verification. Preserve Go release voting and verified Core dependency adoption. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc64f14-49a5-4a09-8b61-fd3ee5500435 Signed-off-by: Yi Zha <107919912+yizha1@users.noreply.github.com> --- .github/MONTHLY_PATCH.md | 57 +++++++++++++++++---- .github/workflows/monthly-patch-release.yml | 4 +- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/.github/MONTHLY_PATCH.md b/.github/MONTHLY_PATCH.md index d4a4add8..bf7d2851 100644 --- a/.github/MONTHLY_PATCH.md +++ b/.github/MONTHLY_PATCH.md @@ -15,7 +15,7 @@ companion CLI controller and this repository's caller must both be installed before activation. The draft's reusable-workflow pin must be rebound to the final upstream CLI commit after the companion PR merges, before Go merges or activates. Squash merging can change that commit SHA. -The current review pin is `16fcf79278a01b403233170611f94eb665bdde07`. +The current review pin is `8ccc041e0bc2afc5c8a99cda24abd280cdffbd58`. Maintainers must update both the caller's `uses` reference and `worker_sha` input to the same final upstream commit. @@ -46,12 +46,24 @@ all repository secrets. Go has its own third-party dependency updates. A Go cycle waits for Core only when Core publishes a release in the same coordinated cycle. In that case, the exact planned canonical `github.com/notaryproject/notation-core-go` version must -pass the checked producer PR before release. Fork `replace` directives are not -an acceptable substitute. +pass the checked producer PR before release. Core must first pass public release +verification. The controller must then reassess Go with that exact producer +version; an earlier assessment with no Go updates is not a final skip decision. +Fork `replace` directives are not an acceptable substitute. -The CLI controller owns cycle assessment, policy authorization, and coordination. -Maintainers must approve that policy and activation explicitly. Worker execution -must honor the authorized cycle and preserve the release approval points in +The CLI controller owns cycle assessment and coordination. A maintainer must +review and approve the shared plan issue before execution starts. The approval +must bind the reviewed plan and cannot be inferred from setup approval, a timer, +or a manual dispatch. A current active CLI repo-level maintainer must post the +exact, unedited comment `/approve-plan ` on the shared central cycle +issue showing the immutable plan. The controller checks the issue body, plan +reference, policy, roster, and comment on approval and subsequent writes. +The controller polls every six hours or on manual dispatch. Editing or deleting +the approval comment revokes remaining execution. +Closing the issue, using a manual approval input, or setting an old automatic +approval flag cannot bypass this gate. A changed plan requires a new approval +comment. +Worker execution must preserve the release approval points in [Release Checklist](../RELEASE_CHECKLIST.md), including a majority of repo-level maintainer approvals on the release voting PR, the required merge-commit method, and a signed release tag. Setup approval does not replace release voting. @@ -73,6 +85,29 @@ The worker validates the metadata delta against the exact qualified dependency candidate. A release voting PR can include those recorded backports plus the required metadata update without granting permission for other source changes. +## Draft review and manual publication + +After qualification, the worker creates an actual GitHub draft release. It +opens a repo-local review issue only after the draft's native checks pass. +Maintainers review the draft, qualification evidence, and recorded asset bytes. +This review is separate from both the shared plan +approval and Go's required release voting PR. +Draft-native checks use only the target repository's read-only `github.token` +with Contents read permission. Denied draft access blocks review issue creation; +the checks do not fall back to the release-writing credential. + +A maintainer initially publishes the reviewed draft using GitHub's **Publish +release** action. Automation does not change the draft to a published release. +Automatic publishing would require a separate future opt-in and is not +implemented by this flow. + +The signed Git tag may already be public before the draft review. A GitHub draft +does not imply that its tag or source commit is hidden. After manual publication, +the worker must observe the same release ID, tag, asset IDs, hashes, and sizes, +then run anonymous native public checks. Only successful verification allows +downstream consumers to advance. An absent publication or mismatched release remains +blocked; the worker must not substitute another release. + ## Activation 1. Maintainers review and merge the companion CLI controller and the @@ -82,12 +117,12 @@ required metadata update without granting permission for other source changes. 2. Administrators configure the settings above and confirm the required CI and review rules apply to the selected stable release branch. 3. Maintainers review the CLI's centralized tests and the Go caller's workflow - lint and contract checks, - and approve the controller's authorization policy and activation. + lint and contract checks, and approve setup and activation. These approvals + do not replace each cycle's plan approval or release reviews. 4. Administrators set `MONTHLY_PATCH_ENABLED` to `true` only after those approvals. They can return it to `false` to disable future worker execution. The worker defaults to a no-write `dry-run`. Canonical execution requires a -controller-authorized cycle, plan digest, and attempt from the CLI, along with -the enabled setting and configured actor. A manual dispatch alone does not -authorize execution. +maintainer-approved shared plan, controller-authorized cycle, plan digest, and +attempt from the CLI, along with the enabled setting and configured actor. +A manual dispatch alone does not authorize execution or publication. diff --git a/.github/workflows/monthly-patch-release.yml b/.github/workflows/monthly-patch-release.yml index a70f3bb0..4339a9c3 100644 --- a/.github/workflows/monthly-patch-release.yml +++ b/.github/workflows/monthly-patch-release.yml @@ -51,14 +51,14 @@ permissions: jobs: release: - uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@16fcf79278a01b403233170611f94eb665bdde07 + uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@8ccc041e0bc2afc5c8a99cda24abd280cdffbd58 with: mode: ${{ inputs.mode }} month: ${{ inputs.month }} controller_issue: ${{ inputs.controller_issue }} plan_id: ${{ inputs.plan_id }} attempt: ${{ inputs.attempt }} - worker_sha: 16fcf79278a01b403233170611f94eb665bdde07 + worker_sha: 8ccc041e0bc2afc5c8a99cda24abd280cdffbd58 secrets: MONTHLY_PATCH_TOKEN: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN || '' }} MONTHLY_PATCH_SIGNING_KEY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY || '' }} From de5c4d5544b7c15bfa9835dea254887b934e2765 Mon Sep 17 00:00:00 2001 From: Yi Zha <107919912+yizha1@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:55:47 -0700 Subject: [PATCH 4/5] Pin release issue failure notifications and document delivery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/MONTHLY_PATCH.md | 12 +++++++++++- .github/workflows/monthly-patch-release.yml | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/MONTHLY_PATCH.md b/.github/MONTHLY_PATCH.md index bf7d2851..224055f2 100644 --- a/.github/MONTHLY_PATCH.md +++ b/.github/MONTHLY_PATCH.md @@ -15,7 +15,7 @@ companion CLI controller and this repository's caller must both be installed before activation. The draft's reusable-workflow pin must be rebound to the final upstream CLI commit after the companion PR merges, before Go merges or activates. Squash merging can change that commit SHA. -The current review pin is `8ccc041e0bc2afc5c8a99cda24abd280cdffbd58`. +The current review pin is `ac34149a6941887e66132b7a30f38609498745c4`. Maintainers must update both the caller's `uses` reference and `worker_sha` input to the same final upstream commit. @@ -108,6 +108,16 @@ then run anonymous native public checks. Only successful verification allows downstream consumers to advance. An absent publication or mismatched release remains blocked; the worker must not substitute another release. +## Failure notifications + +The controller reports a failed gate on the shared CLI release-plan issue, +mentions maintainers, and links the repository, failed stage/run, evidence, +and blocked downstream work. Reconciliation does not duplicate an alert; +failed retries get a new comment. Comments do not rewrite the approved scope +or waive checks. Subscribe to the issue and configure GitHub notifications; +email delivery is not guaranteed. If no trusted issue exists, Actions logs +and failed-workflow notifications are the fallback. + ## Activation 1. Maintainers review and merge the companion CLI controller and the diff --git a/.github/workflows/monthly-patch-release.yml b/.github/workflows/monthly-patch-release.yml index 4339a9c3..cc190a3b 100644 --- a/.github/workflows/monthly-patch-release.yml +++ b/.github/workflows/monthly-patch-release.yml @@ -51,14 +51,14 @@ permissions: jobs: release: - uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@8ccc041e0bc2afc5c8a99cda24abd280cdffbd58 + uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@ac34149a6941887e66132b7a30f38609498745c4 with: mode: ${{ inputs.mode }} month: ${{ inputs.month }} controller_issue: ${{ inputs.controller_issue }} plan_id: ${{ inputs.plan_id }} attempt: ${{ inputs.attempt }} - worker_sha: 8ccc041e0bc2afc5c8a99cda24abd280cdffbd58 + worker_sha: ac34149a6941887e66132b7a30f38609498745c4 secrets: MONTHLY_PATCH_TOKEN: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN || '' }} MONTHLY_PATCH_SIGNING_KEY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY || '' }} From d82de24ed62782aa737de7949d6035f3d30e5042 Mon Sep 17 00:00:00 2001 From: Yi Zha <107919912+yizha1@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:51:02 -0700 Subject: [PATCH 5/5] feat(release): use repository-owned release issues Pin the shared worker, document concise scope and targeted notifications, and retain disabled stable releases until pre-tag approval exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/MONTHLY_PATCH.md | 54 +++++++++++++++------ .github/workflows/monthly-patch-release.yml | 4 +- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/.github/MONTHLY_PATCH.md b/.github/MONTHLY_PATCH.md index 224055f2..dc70bc40 100644 --- a/.github/MONTHLY_PATCH.md +++ b/.github/MONTHLY_PATCH.md @@ -15,7 +15,7 @@ companion CLI controller and this repository's caller must both be installed before activation. The draft's reusable-workflow pin must be rebound to the final upstream CLI commit after the companion PR merges, before Go merges or activates. Squash merging can change that commit SHA. -The current review pin is `ac34149a6941887e66132b7a30f38609498745c4`. +The current review pin is `953678d7760d177da9e3f4f61e928df8427eee24`. Maintainers must update both the caller's `uses` reference and `worker_sha` input to the same final upstream commit. @@ -51,11 +51,16 @@ verification. The controller must then reassess Go with that exact producer version; an earlier assessment with no Go updates is not a final skip decision. Fork `replace` directives are not an acceptable substitute. -The CLI controller owns cycle assessment and coordination. A maintainer must -review and approve the shared plan issue before execution starts. The approval +The CLI controller owns cycle assessment and coordination. Each repository has +one release issue with its month, current/proposed versions, own changes, +dependency issue links, status, and maintainer action. Go links Core's issue +without repeating Core's change list. The CLI issue provides overall status +and links to both library issues. + +A maintainer must review and approve the overall plan before execution starts. The approval must bind the reviewed plan and cannot be inferred from setup approval, a timer, or a manual dispatch. A current active CLI repo-level maintainer must post the -exact, unedited comment `/approve-plan ` on the shared central cycle +exact, unedited comment `/approve` on the CLI release issue showing the immutable plan. The controller checks the issue body, plan reference, policy, roster, and comment on approval and subsequent writes. The controller polls every six hours or on manual dispatch. Editing or deleting @@ -63,6 +68,12 @@ the approval comment revokes remaining execution. Closing the issue, using a manual approval input, or setting an old automatic approval flag cannot bypass this gate. A changed plan requires a new approval comment. + +Assessment also reviews new `main` commits for evidence-backed bug fixes +applicable to the stable release line. It lists candidates separately for +maintainer review and does not automatically backport them. Features are +excluded, and adding a fix to an approved scope requires a new approval. + Worker execution must preserve the release approval points in [Release Checklist](../RELEASE_CHECKLIST.md), including a majority of repo-level maintainer approvals on the release voting PR, the required merge-commit method, @@ -87,8 +98,9 @@ required metadata update without granting permission for other source changes. ## Draft review and manual publication -After qualification, the worker creates an actual GitHub draft release. It -opens a repo-local review issue only after the draft's native checks pass. +After qualification, the worker creates an actual GitHub draft release. Once +native draft checks pass, it posts the review request to Go's existing release +issue instead of opening another review issue. Maintainers review the draft, qualification evidence, and recorded asset bytes. This review is separate from both the shared plan approval and Go's required release voting PR. @@ -101,8 +113,13 @@ release** action. Automation does not change the draft to a published release. Automatic publishing would require a separate future opt-in and is not implemented by this flow. -The signed Git tag may already be public before the draft review. A GitHub draft -does not imply that its tag or source commit is hidden. After manual publication, +The current worker creates the signed tag before draft review. A GitHub draft +does not hide that tag or prevent Go module downloads. The required maintainer +approval before final stable-tag creation is not implemented; official stable +releases must remain disabled until that separate gate is implemented and +validated. + +After manual publication, the worker must observe the same release ID, tag, asset IDs, hashes, and sizes, then run anonymous native public checks. Only successful verification allows downstream consumers to advance. An absent publication or mismatched release remains @@ -110,13 +127,16 @@ blocked; the worker must not substitute another release. ## Failure notifications -The controller reports a failed gate on the shared CLI release-plan issue, -mentions maintainers, and links the repository, failed stage/run, evidence, -and blocked downstream work. Reconciliation does not duplicate an alert; -failed retries get a new comment. Comments do not rewrite the approved scope -or waive checks. Subscribe to the issue and configure GitHub notifications; -email delivery is not guaranteed. If no trusted issue exists, Actions logs -and failed-workflow notifications are the fallback. +The controller reports errors and blockers in the affected repository's release +issue, mentions its maintainers, and links the failed stage/run and evidence. +Dependent issues receive only a concise blocker and a link to the affected issue. +The same issue receives the draft-ready review request. Routine progress does not +generate comments. Reconciliation deduplicates alerts; a new failed retry can +produce a new alert without rewriting approved scope or waiving checks. + +Subscribe to Go's release issue and configure GitHub notifications; email +delivery is not guaranteed. If no trusted issue exists, Actions logs and +failed-workflow notifications are the fallback. ## Activation @@ -129,7 +149,9 @@ and failed-workflow notifications are the fallback. 3. Maintainers review the CLI's centralized tests and the Go caller's workflow lint and contract checks, and approve setup and activation. These approvals do not replace each cycle's plan approval or release reviews. -4. Administrators set `MONTHLY_PATCH_ENABLED` to `true` only after those approvals. +4. Administrators keep official stable releases disabled until the pre-tag + approval gap above is resolved. They set `MONTHLY_PATCH_ENABLED` to `true` + only after that gate is validated and the required approvals are obtained. They can return it to `false` to disable future worker execution. The worker defaults to a no-write `dry-run`. Canonical execution requires a diff --git a/.github/workflows/monthly-patch-release.yml b/.github/workflows/monthly-patch-release.yml index cc190a3b..907c6057 100644 --- a/.github/workflows/monthly-patch-release.yml +++ b/.github/workflows/monthly-patch-release.yml @@ -51,14 +51,14 @@ permissions: jobs: release: - uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@ac34149a6941887e66132b7a30f38609498745c4 + uses: notaryproject/notation/.github/workflows/monthly-patch-worker.yml@953678d7760d177da9e3f4f61e928df8427eee24 with: mode: ${{ inputs.mode }} month: ${{ inputs.month }} controller_issue: ${{ inputs.controller_issue }} plan_id: ${{ inputs.plan_id }} attempt: ${{ inputs.attempt }} - worker_sha: ac34149a6941887e66132b7a30f38609498745c4 + worker_sha: 953678d7760d177da9e3f4f61e928df8427eee24 secrets: MONTHLY_PATCH_TOKEN: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_TOKEN || '' }} MONTHLY_PATCH_SIGNING_KEY: ${{ inputs.mode == 'execute' && secrets.MONTHLY_PATCH_SIGNING_KEY || '' }}