diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bd559a3f..3d3d1afbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,6 +149,7 @@ jobs: - name: The tools of the CI stages pass their fixtures run: | python3 tests/scripts/test_classify_changes.py + python3 tests/scripts/test_notify_packages.py python3 tests/scripts/test_check_e2e_coverage.py python3 tests/scripts/test_check_default_toolchain_docs.py python3 tests/scripts/test_check_provider_boundary.py diff --git a/.github/workflows/homebrew-publish.yml b/.github/workflows/homebrew-publish.yml deleted file mode 100644 index 858e6e494..000000000 --- a/.github/workflows/homebrew-publish.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: homebrew-publish - -# Tell the Homebrew tap (mcpp-community/homebrew-mcpp) that a release is out. -# -# The tap owns the formula rewrite — it reads the .sha256 sidecars from the -# release and commits the new url/version itself. All this workflow does is -# fire the starting gun, so nothing here needs to know what a formula is. -# -# Triggers on COMPLETION of the `release` workflow rather than on -# `release: published`, for the same reason aur-publish.yml does: release.yml -# creates the GitHub Release in its first job but uploads the macOS / aarch64 -# assets in later jobs, and the tap needs every sidecar to exist. -# -# Requires one repository secret: -# HOMEBREW_TAP_TOKEN — fine-grained PAT scoped to mcpp-community/homebrew-mcpp -# with "Contents: read and write" (repository_dispatch -# is a write-level API). -# -# The secret is OPTIONAL. Without it this workflow logs a notice and exits 0; -# the tap runs the same bump on a daily schedule, so a missing token costs -# freshness (up to 24h), not correctness. That keeps a release from failing -# over a credential the release itself doesn't need. - -on: - workflow_run: - workflows: [release] - types: [completed] - workflow_dispatch: - inputs: - version: - description: "Version to publish (default: [package].version in mcpp.toml)" - required: false - -concurrency: - group: homebrew-publish - cancel-in-progress: false - -jobs: - notify-tap: - runs-on: ubuntu-latest - # On the workflow_run trigger, only proceed if the release actually - # succeeded (skip failed/cancelled release runs). - if: >- - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' - steps: - - name: Checkout released commit - uses: actions/checkout@v4 - with: - # workflow_run: the exact commit the release was built from. - # workflow_dispatch: default ref (HEAD of the branch). - ref: ${{ github.event.workflow_run.head_sha || github.ref }} - - - name: Resolve version - id: resolve - run: | - VER="${{ github.event.inputs.version }}" - if [ -z "$VER" ]; then - # mcpp.toml at the released commit carries the right version. - VER=$(grep -m1 -E '^\s*version\s*=' mcpp.toml | sed -E 's/.*"([^"]+)".*/\1/') - fi - [ -n "$VER" ] || { echo "cannot resolve version"; exit 1; } - echo "version=$VER" >> "$GITHUB_OUTPUT" - echo ":: version $VER" - - - name: Ping the tap - env: - TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - VER: ${{ steps.resolve.outputs.version }} - run: | - set -eu - if [ -z "${TAP_TOKEN}" ]; then - echo "::notice::HOMEBREW_TAP_TOKEN is not configured; skipping the ping. mcpp-community/homebrew-mcpp bumps itself on a daily schedule, so ${VER} reaches the tap within 24h." - exit 0 - fi - curl -fsS -X POST \ - -H "Authorization: Bearer ${TAP_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/mcpp-community/homebrew-mcpp/dispatches \ - -d "{\"event_type\":\"mcpp-release\",\"client_payload\":{\"version\":\"${VER}\"}}" - echo ":: dispatched mcpp-release ${VER} to mcpp-community/homebrew-mcpp" - echo "Pinged the Homebrew tap for **${VER}**." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 000000000..1c816f044 --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,119 @@ +name: publish-packages + +# Notify repositories that own their package definitions. PyPI and AUR retain +# their own publishing workflows, credentials and verification contracts. +# Each target accepts mcpp-release and has a scheduled catch-up as a fallback. +# HOMEBREW_TAP_TOKEN and NIX_PUBLISH_TOKEN are optional fine-grained PATs with +# Contents: write on homebrew-mcpp and nix-mcpp respectively. Missing tokens +# are reported as skipped; accepted dispatches are not publication success. +on: + workflow_run: + workflows: [release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: 'Exact complete stable release tag, e.g. v2026.10.17.1' + type: string + required: true + channel: + description: 'Repository to notify' + type: choice + options: [all, homebrew, nix] + default: all + +permissions: + contents: read + +concurrency: + group: publish-packages + cancel-in-progress: false + +jobs: + resolve: + name: resolve the sealed release + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + tag: ${{ steps.identity.outputs.tag }} + version: ${{ steps.identity.outputs.version }} + commit: ${{ steps.identity.outputs.commit }} + matrix: ${{ steps.identity.outputs.matrix }} + steps: + # Execute trusted notification code, never code from a downloaded artifact. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Read the manifest sealed by the completed release run + if: github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + with: + name: mcpp-release-manifest + path: release-identity + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Download the manually selected release manifest + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)+$ ]] || { echo 'Expected a stable release tag'; exit 1; } + mkdir -p release-identity + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + -p mcpp-release.json -O release-identity/mcpp-release.json + + - name: Validate the manifest and select notification targets + id: identity + env: + REQUESTED_TAG: ${{ inputs.tag }} + CHANNEL: ${{ inputs.channel || 'all' }} + run: | + python3 tools/release/notify_packages.py resolve \ + --manifest release-identity/mcpp-release.json \ + --expected-tag "$REQUESTED_TAG" --channel "$CHANNEL" + + - name: The public manifest matches the sealed release identity + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.identity.outputs.tag }} + run: | + set -euo pipefail + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + -p mcpp-release.json -O public-mcpp-release.json + cmp release-identity/mcpp-release.json public-mcpp-release.json + + notify: + name: notify ${{ matrix.channel }} + needs: resolve + runs-on: ubuntu-24.04 + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Send the release notification and report acceptance + env: + GH_TOKEN: ${{ secrets[matrix.token] }} + CHANNEL: ${{ matrix.channel }} + TAG: ${{ needs.resolve.outputs.tag }} + VERSION: ${{ needs.resolve.outputs.version }} + COMMIT: ${{ needs.resolve.outputs.commit }} + run: | + python3 tools/release/notify_packages.py notify \ + --channel "$CHANNEL" --tag "$TAG" --version "$VERSION" --commit "$COMMIT" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index fd7d18ad3..321ff2dee 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -2,7 +2,7 @@ name: pypi-publish # Publish the `mcpp-bin` wheels to PyPI (`pip install mcpp-bin`). # -# Downstream of `release`, like aur-publish.yml and homebrew-publish.yml: the +# Downstream of `release`, like aur-publish.yml and publish-packages.yml: the # wheels are built from the release's own mcpp-release.json and payloads, so # this runs only once the release workflow has completed. # diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e0e73282..fb962774a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -879,6 +879,18 @@ jobs: cmp "$AUDIT_ROOT/final-assets/mcpp-release.json" \ "$AUDIT_ROOT/final-expected.json" cat "$AUDIT_ROOT/final-assets/mcpp-release.json" + echo "SEALED_RELEASE_MANIFEST=$AUDIT_ROOT/final-assets/mcpp-release.json" >> "$GITHUB_ENV" + + # Downstream notifications consume the identity sealed by THIS run, + # including workflow_dispatch rebuilds of a tag other than main's version. + - name: Save the sealed manifest for package notifications + uses: actions/upload-artifact@v4 + with: + name: mcpp-release-manifest + path: ${{ env.SEALED_RELEASE_MANIFEST }} + if-no-files-found: error + overwrite: true # A rerun has already proved the public bytes identical. + retention-days: 30 # Publish this release into the xlings ecosystem, after ALL platform builds # have uploaded their assets and the immutable manifest gate has passed: diff --git a/tests/scripts/test_notify_packages.py b/tests/scripts/test_notify_packages.py new file mode 100644 index 000000000..13c45ccbb --- /dev/null +++ b/tests/scripts/test_notify_packages.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Package notification contracts; no real dispatches or credentials.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "tools/release/notify_packages.py" +sys.path.insert(0, str(SCRIPT.parent)) +spec = importlib.util.spec_from_file_location("notify_packages", SCRIPT) +notify = importlib.util.module_from_spec(spec) +spec.loader.exec_module(notify) +sys.path.pop(0) + +VERSION = "2026.10.17.1" +TAG = f"v{VERSION}" +COMMIT = "a" * 40 + + +def manifest(): + return { + "schema": 1, "tag": TAG, "version": VERSION, "commit": COMMIT, + "assets": [{"name": name, "sha256": "b" * 64} + for name in sorted(notify.required_primary_assets(VERSION))], + } + + +class Resolve(unittest.TestCase): + def test_exact_manifest_identity_wins_over_checkout_version(self): + # The release may rebuild an old tag from a workflow started on main. + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "mcpp.toml").write_text('[package]\nversion = "2099.1.1.1"\n') + (path / "manifest.json").write_text(json.dumps(manifest())) + env = dict(os.environ, GITHUB_OUTPUT=str(path / "output")) + result = subprocess.run([ + sys.executable, str(SCRIPT), "resolve", "--manifest", str(path / "manifest.json"), + ], cwd=path, env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"tag={TAG}\n", (path / "output").read_text()) + self.assertIn(f"commit={COMMIT}\n", result.stdout) + self.assertNotIn("2099", result.stdout) + + def test_all_channels_and_single_channel_retry(self): + values = notify.resolve(manifest(), "", "all") + self.assertEqual([row["channel"] for row in json.loads(values["matrix"])["include"]], + ["homebrew", "nix"]) + values = notify.resolve(manifest(), TAG, "nix") + rows = json.loads(values["matrix"])["include"] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["channel"], "nix") + + def test_rejects_wrong_requested_tag(self): + with self.assertRaises(notify.ManifestError): + notify.resolve(manifest(), "v2026.10.16.3", "all") + + def test_rejects_invalid_identity_and_schema(self): + for key, value in [("schema", 2), ("schema", True), ("version", "1\nx=y"), + ("version", "2026.10.17.1-rc1"), ("tag", "vOTHER"), + ("commit", "short"), ("commit", None)]: + with self.subTest(key=key, value=value): + data = manifest() + data[key] = value + with self.assertRaises(notify.ManifestError): + notify.resolve(data, "", "all") + + def test_rejects_incomplete_or_corrupt_assets(self): + for kind in ("missing", "duplicate", "digest", "type"): + with self.subTest(kind=kind): + data = manifest() + if kind == "missing": + data["assets"].pop() + elif kind == "duplicate": + data["assets"].append(copy.deepcopy(data["assets"][0])) + elif kind == "digest": + data["assets"][0]["sha256"] = "invalid" + else: + data["assets"] = None + with self.assertRaises(notify.ManifestError): + notify.resolve(data, "", "all") + + def test_malformed_manifest_cli_fails_without_outputs(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "manifest.json").write_text("[]") + result = subprocess.run([ + sys.executable, str(SCRIPT), "resolve", "--manifest", str(path / "manifest.json"), + ], env=dict(os.environ, GITHUB_OUTPUT=str(path / "output")), capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((path / "output").exists()) + + +class Dispatch(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.summary = Path(self.directory.name) / "summary" + self.environment = patch.dict(os.environ, { + "GH_TOKEN": "fake-token", "GITHUB_STEP_SUMMARY": str(self.summary), + }) + self.environment.start() + self.addCleanup(self.environment.stop) + + def test_each_channel_gets_the_exact_identity(self): + for channel, repository in [("homebrew", "mcpp-community/homebrew-mcpp"), + ("nix", "mcpp-community/nix-mcpp")]: + with self.subTest(channel=channel), patch.object(notify.subprocess, "run") as run: + run.return_value.returncode = 0 + self.assertEqual(notify.notify(channel, TAG, VERSION, COMMIT), 0) + args, kwargs = run.call_args + self.assertIn(f"repos/{repository}/dispatches", args[0]) + payload = json.loads(kwargs["input"]) + self.assertEqual(payload["event_type"], "mcpp-release") + self.assertEqual(payload["client_payload"]["version"], VERSION) + self.assertEqual(payload["client_payload"]["tag"], TAG) + self.assertEqual(payload["client_payload"]["commit"], COMMIT) + self.assertIn(f"/{TAG}/mcpp-release.json", payload["client_payload"]["manifest_url"]) + self.assertNotIn("fake-token", str(run.call_args)) + self.assertIn("Notification accepted", self.summary.read_text()) + self.assertIn("downstream workflow", self.summary.read_text()) + + def test_missing_token_skips_without_calling_github(self): + with patch.dict(os.environ, {"GH_TOKEN": ""}), patch.object(notify.subprocess, "run") as run: + self.assertEqual(notify.notify("nix", TAG, VERSION, COMMIT), 0) + run.assert_not_called() + self.assertIn("Skipped", self.summary.read_text()) + self.assertIn("NIX_PUBLISH_TOKEN", self.summary.read_text()) + + def test_dispatch_failure_is_reported_and_does_not_block_other_channel(self): + with patch.object(notify.subprocess, "run") as run: + run.return_value.returncode = 1 + self.assertEqual(notify.notify("homebrew", TAG, VERSION, COMMIT), 1) + run.return_value.returncode = 0 + self.assertEqual(notify.notify("nix", TAG, VERSION, COMMIT), 0) + summary = self.summary.read_text() + self.assertIn("Failed to notify homebrew", summary) + self.assertIn("Notification accepted by nix", summary) + + def test_invalid_identity_never_dispatches(self): + with patch.object(notify.subprocess, "run") as run: + with self.assertRaises(notify.ManifestError): + notify.notify("nix", TAG, "1\nINJECTED=value", COMMIT) + run.assert_not_called() + + +class WorkflowContract(unittest.TestCase): + def test_consumer_uses_completed_run_artifact_instead_of_head_version(self): + producer = (ROOT / ".github/workflows/release.yml").read_text() + consumer = (ROOT / ".github/workflows/publish-packages.yml").read_text() + self.assertIn("name: mcpp-release-manifest", producer) + self.assertIn("name: mcpp-release-manifest", consumer) + self.assertIn("run-id: ${{ github.event.workflow_run.id }}", consumer) + self.assertNotIn("workflow_run.head_sha", consumer) + self.assertNotIn("mcpp.toml", consumer) + self.assertIn("fail-fast: false", consumer) + self.assertFalse((ROOT / ".github/workflows/homebrew-publish.yml").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/notify_packages.py b/tools/release/notify_packages.py new file mode 100644 index 000000000..3cd50e094 --- /dev/null +++ b/tools/release/notify_packages.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Resolve a sealed release and notify repositories that maintain packages.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +from generate_manifest import COMMIT_RE, ManifestError, load_release, required_primary_assets + + +TARGETS = { + "homebrew": ("mcpp-community/homebrew-mcpp", "HOMEBREW_TAP_TOKEN"), + "nix": ("mcpp-community/nix-mcpp", "NIX_PUBLISH_TOKEN"), +} +STABLE_VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)+\Z") + + +def validate_identity(tag: str, version: str, commit: str) -> None: + if not STABLE_VERSION_RE.fullmatch(version) or tag != f"v{version}": + raise ManifestError("expected matching stable numeric version and v-prefixed tag") + if not COMMIT_RE.fullmatch(commit): + raise ManifestError("expected a full commit object ID") + + +def resolve(manifest: dict, expected_tag: str, channel: str) -> dict[str, str]: + if type(manifest.get("schema")) is not int or manifest["schema"] != 1: + raise ManifestError("expected release manifest schema 1") + identity = {key: manifest.get(key) for key in ("tag", "version", "commit")} + if not all(isinstance(value, str) for value in identity.values()): + raise ManifestError("manifest tag, version and commit must be strings") + validate_identity(**identity) + if expected_tag and identity["tag"] != expected_tag: + raise ManifestError("manifest does not match the requested tag") + assets = manifest.get("assets") + if not isinstance(assets, list): + raise ManifestError("manifest assets must be an array") + names = set() + for asset in assets: + if not isinstance(asset, dict): + raise ManifestError("manifest asset must be an object") + name, digest = asset.get("name"), asset.get("sha256") + if not isinstance(name, str) or name in names: + raise ManifestError("manifest asset names must be unique strings") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest): + raise ManifestError("manifest asset must have a SHA-256 digest") + names.add(name) + if not required_primary_assets(identity["version"]).issubset(names): + raise ManifestError("manifest is missing required platform assets") + channels = TARGETS if channel == "all" else {channel: TARGETS[channel]} + identity["matrix"] = json.dumps({"include": [ + {"channel": name, "token": token} for name, (_, token) in channels.items() + ]}, separators=(",", ":")) + return identity + + +def report(message: str) -> None: + print(message) + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(summary, "a", encoding="utf-8") as stream: + stream.write(message + "\n") + + +def notify(channel: str, tag: str, version: str, commit: str) -> int: + validate_identity(tag, version, commit) + repository, token_name = TARGETS[channel] + if not os.environ.get("GH_TOKEN"): + report(f"Skipped {channel} notification for {tag}: {token_name} is not configured. " + "The downstream scheduled update remains available.") + return 0 + payload = {"event_type": "mcpp-release", "client_payload": { + "version": version, "tag": tag, "commit": commit, + "manifest_url": f"https://github.com/mcpp-community/mcpp/releases/download/{tag}/mcpp-release.json", + }} + result = subprocess.run([ + "gh", "api", "--method", "POST", f"repos/{repository}/dispatches", "--input", "-", + ], input=json.dumps(payload), text=True, check=False) + if result.returncode: + report(f"Failed to notify {channel} ({repository}) for {tag}; retry this channel manually.") + else: + report(f"Notification accepted by {channel} ({repository}) for {tag}. " + f"Publication is verified by the [downstream workflow](https://github.com/{repository}/actions).") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + resolver = commands.add_parser("resolve") + resolver.add_argument("--manifest", type=Path, required=True) + resolver.add_argument("--expected-tag", default="") + resolver.add_argument("--channel", choices=["all", *TARGETS], default="all") + notifier = commands.add_parser("notify") + notifier.add_argument("--channel", choices=list(TARGETS), required=True) + for key in ("tag", "version", "commit"): + notifier.add_argument(f"--{key}", required=True) + args = parser.parse_args() + try: + if args.command == "notify": + return notify(args.channel, args.tag, args.version, args.commit) + values = resolve(load_release(args.manifest), args.expected_tag, args.channel) + for key, value in values.items(): + print(f"{key}={value}") + if output := os.environ.get("GITHUB_OUTPUT"): + with open(output, "a", encoding="utf-8") as stream: + for key, value in values.items(): + stream.write(f"{key}={value}\n") + return 0 + except (ManifestError, OSError) as exc: + print(f"package notification failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())