Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
154 changes: 154 additions & 0 deletions scripts/release_publication.py
Original file line number Diff line number Diff line change
@@ -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)
98 changes: 98 additions & 0 deletions scripts/test_release_publication.py
Original file line number Diff line number Diff line change
@@ -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()
Loading