diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b83d25e..3e0639a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -16,7 +16,41 @@ concurrency: cancel-in-progress: false jobs: + release: + if: github.repository_id == '1353589608' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: master + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.11" + - name: Import latest official public release + env: + GH_TOKEN: ${{ github.token }} + run: npm run release:update -- latest + - name: Create release synchronization token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: automation + with: + client-id: ${{ vars.AUTOMATION_APP_CLIENT_ID }} + private-key: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - name: Deliver verified release record + env: + GH_TOKEN: ${{ steps.automation.outputs.token }} + run: python3 scripts/release_publication.py + build: + needs: release if: github.repository_id == '1353589608' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/README.md b/README.md index 833af7c..7a9e187 100644 --- a/README.md +++ b/README.md @@ -43,17 +43,18 @@ Content verification covers `main`, `master` and full commit hashes; other conte validation. `public/release.json` binds the deployed site to the product's delivered commit and release tag. The -private website package version describes this build project, not the product version. After the -main repository publishes an accepted release, run `npm run release:update -- vX.Y.Z` to import its -`release.json` asset. The importer verifies the official repository, public stable release, tag -commit and GitHub asset digest; it refuses version regressions and changed identities for an -existing version. It never derives a release from an installed App or local source checkout. Commit -the generated record with the website delivery. - +private website package version describes this build project, not the product version. Pages +reconciles the latest public stable product release hourly, on master pushes and on manual dispatch. +The importer verifies the official repository, public release, tag commit and GitHub asset digest; +it refuses version regressions and changed identities for an existing version. The automation App +creates a signed proposal, required CI authorizes its automatic merge, and Pages deploys current +canonical master. Import, merge or deployment failure preserves the published site. + +Manual import is available through `npm run release:update -- latest` or an explicit `vX.Y.Z` tag. `npm run release:check` checks the local record without changing it. -`npm run release:verify-public -- vX.Y.Z` also compares it with the official public asset. -Historical releases without a delivery-record asset retain their existing record until the next -product delivery. Tests read the record rather than maintain another product-version constant. +`npm run release:verify-public -- latest` compares it with the official public asset. Historical +releases without a delivery-record asset retain their existing record until the next product +delivery. Tests read the record rather than maintain another product-version constant. The plugin catalog publisher generates a separate versioned snapshot of verified current official plugin releases: the latest stable and any newer prerelease for each repository. Host and plugin diff --git a/package.json b/package.json index 22b7e4e..3801f63 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "format:check": "prettier --check .", "check:html": "html-validate index.html guide/index.html", "check:links": "node scripts/check-links.mjs", - "test": "npm run catalog:test && node --test scripts/release.test.mjs scripts/check-links.test.mjs && playwright test", + "test": "npm run catalog:test && npm run release:test && node --test scripts/release.test.mjs scripts/check-links.test.mjs && playwright test", "catalog:test": "python3 -m unittest discover -s scripts -p 'test_plugin_catalog*.py'", "catalog:check": "python3 scripts/plugin_catalog.py check", "catalog:check-policy": "python3 scripts/plugin_catalog.py check-policy", "catalog:update": "python3 scripts/plugin_catalog.py update", + "release:test": "python3 -m unittest discover -s scripts -p 'test_release_publication.py'", "release:check": "node scripts/release.mjs check", "release:update": "node scripts/release.mjs update", "release:verify-public": "node scripts/release.mjs verify-public", diff --git a/scripts/release.mjs b/scripts/release.mjs index 8683552..01a383b 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -93,9 +93,15 @@ function main(args) { return; } if (args.length !== 2 || !["update", "verify-public"].includes(args[0])) { - throw new Error("Usage: release.mjs check | update vX.Y.Z | verify-public vX.Y.Z"); + throw new Error( + "Usage: release.mjs check | update latest|vX.Y.Z | verify-public latest|vX.Y.Z", + ); } - const record = publishedRecord(args[1]); + const tag = + args[1] === "latest" + ? JSON.parse(gh("api", `repos/${repository}/releases/latest`)).tag_name + : args[1]; + const record = publishedRecord(tag); checkForward(previous, record); if (args[0] === "update") { writeFileSync(destination, `${JSON.stringify(record, null, 2)}\n`); diff --git a/scripts/release_publication.py b/scripts/release_publication.py new file mode 100644 index 0000000..dd5129b --- /dev/null +++ b/scripts/release_publication.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Publish the official release delivery record through a signed website pull request.""" +import base64 +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time +import urllib.request + +ROOT = Path(__file__).resolve().parent.parent +REPOSITORY = "computer-mcp/computer-mcp.github.io" +PATH = "public/release.json" +BRANCH = "automation/product-release" + + +def git(*args, data=None, environment=None): + return subprocess.check_output(["git", "--no-optional-locks", "-C", str(ROOT), *args], input=data, + timeout=60, env=environment) + + +def gh(*args): + return subprocess.check_output(["gh", *args, "--repo", REPOSITORY], text=True, timeout=60).strip() + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + raise ValueError("GitHub publication redirects are refused") + + +def verify_record(record): + subprocess.run(["node", str(ROOT / "scripts/release.mjs"), "verify-public", record["release_tag"]], + cwd=ROOT, check=True, timeout=180) + + +def rest(method, path, body): + request = urllib.request.Request("https://api.github.com/repos/" + REPOSITORY + path, + method=method, data=json.dumps(body).encode(), headers={ + "Authorization":"Bearer " + os.environ["GH_TOKEN"], "Accept":"application/vnd.github+json", + "Content-Type":"application/json", "User-Agent":"computer-mcp-release-sync/1"}) + with urllib.request.build_opener(NoRedirect()).open(request, timeout=60) as response: + data = response.read(1024 * 1024 + 1) + if len(data) > 1024 * 1024: + raise ValueError("Publication response exceeds its byte budget") + return json.loads(data) + + +def master(): + result = git("ls-remote", "--exit-code", "origin", "refs/heads/master").decode().split() + if len(result) != 2 or result[1] != "refs/heads/master" or not re.fullmatch(r"[0-9a-f]{40}", result[0]): + raise ValueError("Invalid canonical master identity") + return result[0] + + +def propose(expected_head): + if git("rev-parse", "HEAD").decode().strip() != expected_head or master() != expected_head: + raise ValueError("Website master changed; reconcile current source before deployment") + changes = git("status", "--porcelain=v1", "--untracked-files=normal", "-z") + if changes not in (b"", b" M " + PATH.encode() + b"\0"): + raise ValueError("Only the generated release record may differ") + data = (ROOT / PATH).read_bytes() + if len(data) > 4096: + raise ValueError("Release record exceeds its byte budget") + record = json.loads(data) + verify_record(record) + if not changes: + return None, None + blob = git("hash-object", "-w", "--stdin", data=data).decode().strip() + work = ROOT / ".cache/release-publication" + work.mkdir(parents=True, exist_ok=True) + index = work / "index" + environment = dict(os.environ, GIT_INDEX_FILE=str(index)) + try: + git("read-tree", expected_head, environment=environment) + git("update-index", "--add", "--cacheinfo", f"100644,{blob},{PATH}", environment=environment) + tree = git("write-tree", environment=environment).decode().strip() + finally: + index.unlink(missing_ok=True) + created = rest("POST", "/git/blobs", {"content":base64.b64encode(data).decode(), "encoding":"base64"}) + if created.get("sha") != blob: + raise ValueError("GitHub stored different release bytes") + base = git("rev-parse", expected_head + "^{tree}").decode().strip() + created = rest("POST", "/git/trees", {"base_tree":base, + "tree":[{"path":PATH, "mode":"100644", "type":"blob", "sha":blob}]}) + if created.get("sha") != tree: + raise ValueError("GitHub created a different release tree") + created = rest("POST", "/git/commits", {"message":"Import official Computer MCP " + record["version"] + " release", + "tree":tree, "parents":[expected_head]}) + commit = created.get("sha", "") + if not re.fullmatch(r"[0-9a-f]{40}", commit) or created.get("verification", {}).get("verified") is not True: + raise ValueError("GitHub did not create a verified signed release proposal") + ref = "refs/heads/" + BRANCH + if git("ls-remote", "origin", ref).strip(): + rest("PATCH", "/git/" + ref, {"sha":commit, "force":True}) + else: + rest("POST", "/git/refs", {"ref":ref, "sha":commit}) + git("fetch", "--no-tags", "origin", ref) + if (git("rev-parse", "FETCH_HEAD").decode().strip() != commit + or git("rev-parse", commit + "^{tree}").decode().strip() != tree + or git("rev-list", "--parents", "-n", "1", commit).decode().split() != [commit, expected_head]): + raise ValueError("Signed proposal differs from verified release bytes") + return commit, tree + + +def synchronize(): + if (os.environ.get("GITHUB_ACTIONS") != "true" + or os.environ.get("GITHUB_REPOSITORY_ID") != "1353589608" + or os.environ.get("GITHUB_REF") != "refs/heads/master"): + raise ValueError("Release synchronization requires the official website master workflow") + if git("remote", "get-url", "origin").decode().strip() not in ( + "https://github.com/" + REPOSITORY, "https://github.com/" + REPOSITORY + ".git", + "git@github.com:" + REPOSITORY + ".git"): + raise ValueError("Release synchronization requires the official repository origin") + expected_head = git("rev-parse", "HEAD").decode().strip() + commit, tree = propose(expected_head) + if commit is None: + print("Official release record is current") + return + proposals = json.loads(gh("pr", "list", "--head", BRANCH, "--base", "master", "--state", "open", "--json", "number")) + title = git("log", "-1", "--format=%s", commit).decode().strip() + body = "Import the verified official public release record. Website CI checks the signed proposal before Pages deploys its merged generation." + if proposals: + number = str(proposals[0]["number"]) + gh("pr", "edit", number, "--title", title, "--body", body) + else: + number = gh("pr", "create", "--head", BRANCH, "--base", "master", "--title", title, "--body", body).rsplit("/", 1)[-1] + gh("pr", "merge", number, "--auto", "--squash", "--match-head-commit", commit) + deadline = time.monotonic() + 1200 + while time.monotonic() < deadline: + proposal = json.loads(gh("pr", "view", number, "--json", "state,headRefOid")) + if proposal["headRefOid"] != commit: + raise ValueError("Release proposal identity changed") + if proposal["state"] == "MERGED": + git("fetch", "--no-tags", "origin", "master") + merged = git("rev-parse", "FETCH_HEAD").decode().strip() + if (git("rev-list", "--parents", "-n", "1", merged).decode().split() != [merged, expected_head] + or git("rev-parse", merged + "^{tree}").decode().strip() != tree): + raise ValueError("Merged source differs; reconcile current master before deployment") + print("Official release record merged: " + merged) + return + if proposal["state"] != "OPEN": + raise ValueError("Release proposal closed without delivery") + time.sleep(15) + raise TimeoutError("Release proposal checks or merge exceeded the deadline; deployed site is preserved") + + +if __name__ == "__main__": + try: + synchronize() + except (OSError, ValueError, KeyError, TimeoutError, subprocess.SubprocessError) as error: + print("Release synchronization failed: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/scripts/test_release_publication.py b/scripts/test_release_publication.py new file mode 100644 index 0000000..566c7ca --- /dev/null +++ b/scripts/test_release_publication.py @@ -0,0 +1,98 @@ +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import release_publication as publication +from test_plugin_catalog_publication import RemoteAPI + + +class ReleasePublication(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + base = Path(temporary.name) + self.root, self.remote = base / 'source', base / 'remote.git' + self.root.mkdir() + self.git(base, 'init', '--bare', str(self.remote)) + self.git(self.root, 'init', '-b', 'master') + self.git(self.root, 'remote', 'add', 'origin', str(self.remote)) + (self.root / 'public').mkdir() + self.previous = {'release_tag': 'v1.2.3', 'version': '1.2.3'} + self.write(self.previous) + (self.root / '.gitignore').write_text('.cache/\n') + (self.root / 'index.html').write_text('Website\n') + self.git(self.root, 'add', '.') + self.git(self.root, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-m', 'Fixture') + self.git(self.root, 'push', 'origin', 'master') + self.head = self.git(self.root, 'rev-parse', 'HEAD').strip() + self.current = {'release_tag': 'v1.2.4', 'version': '1.2.4'} + self.write(self.current) + self.api = RemoteAPI(self.remote) + for owner, name, value in ((publication, 'ROOT', self.root), (publication, 'rest', self.api), + (publication, 'verify_record', lambda record: None)): + patcher = patch.object(owner, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def git(self, root, *args): + return subprocess.check_output(['git', '-C', str(root), *args], stderr=subprocess.PIPE, + timeout=10).decode() + + def write(self, record): + (self.root / publication.PATH).write_text(json.dumps(record) + '\n') + + def proposals(self): + return self.git(self.remote, 'for-each-ref', '--format=%(objectname)', + 'refs/heads/' + publication.BRANCH).split() + + def test_signed_proposal_contains_only_verified_record_and_preserves_index(self): + index = (self.root / '.git/index').read_bytes() + commit, tree = publication.propose(self.head) + self.assertEqual(self.proposals(), [commit]) + self.assertEqual(self.git(self.root, 'diff', '--name-only', self.head, commit).strip(), publication.PATH) + self.assertEqual(json.loads(self.git(self.root, 'show', commit + ':' + publication.PATH)), self.current) + self.assertEqual(self.git(self.root, 'rev-parse', commit + '^{tree}').strip(), tree) + self.assertEqual(self.git(self.root, 'rev-parse', 'HEAD').strip(), self.head) + self.assertEqual((self.root / '.git/index').read_bytes(), index) + self.assertFalse((self.root / '.cache/release-publication/index').exists()) + + def test_current_record_does_not_create_a_proposal(self): + self.write(self.previous) + self.assertEqual(publication.propose(self.head), (None, None)) + self.assertEqual(self.api.calls, []) + + def test_unsigned_or_changed_official_record_is_not_published(self): + self.api.verified = False + with self.assertRaisesRegex(ValueError, 'verified signed'): + publication.propose(self.head) + self.assertEqual(self.proposals(), []) + self.api.calls.clear() + with patch.object(publication, 'verify_record', side_effect=ValueError('Public digest differs')): + with self.assertRaisesRegex(ValueError, 'Public digest'): + publication.propose(self.head) + self.assertEqual(self.api.calls, []) + + def test_unrelated_changes_or_changed_master_stop_publication(self): + (self.root / 'index.html').write_text('Unverified source\n') + with self.assertRaisesRegex(ValueError, 'Only the generated'): + publication.propose(self.head) + self.assertEqual(self.api.calls, []) + (self.root / 'index.html').write_text('Website\n') + self.git(self.remote, 'update-ref', 'refs/heads/master', '0' * 40, self.head) + with self.assertRaises(subprocess.CalledProcessError): + publication.propose(self.head) + self.assertEqual(self.api.calls, []) + + def test_different_remote_blob_is_rejected_before_branch_write(self): + with patch.object(publication, 'rest', return_value={'sha': 'a' * 40}): + with self.assertRaisesRegex(ValueError, 'different release bytes'): + publication.propose(self.head) + self.assertEqual(self.proposals(), []) + + +if __name__ == '__main__': + unittest.main()