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
46 changes: 44 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ jobs:
build:
if: github.repository_id == '1353589608' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 45
permissions:
actions: write
contents: write
pages: read
pull-requests: write

steps:
- name: Check out repository
Expand Down Expand Up @@ -82,11 +84,51 @@ jobs:
- name: Run accessibility and smoke tests
run: npm test

- name: Persist verified catalog generation
- name: Prepare verified catalog generation
id: catalog
env:
CATALOG_SOURCE_COMMIT: ${{ steps.source.outputs.commit }}
run: python3 scripts/plugin_catalog_publication.py --expected-head "$CATALOG_SOURCE_COMMIT"

- name: Merge catalog proposal
if: steps.catalog.outputs.proposal != ''
env:
BRANCH: automation/plugin-catalog
GH_TOKEN: ${{ github.token }}
PROPOSAL: ${{ steps.catalog.outputs.proposal }}
RUN_URL:
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
title=$(git log -1 --format=%s "$PROPOSAL")
body="Generated from current official plugin releases by [the Pages workflow]($RUN_URL). The catalog successor, publisher policy and built site were verified before this proposal. Website CI runs on this branch, the pull request merges automatically once it passes, and the same run then deploys the merged generation."
number=$(gh pr list --head "$BRANCH" --base master --state open --json number --jq '.[0].number // empty')
if [[ -n "$number" ]]; then
gh pr edit "$number" --title "$title" --body "$body"
else
number=$(gh pr create --head "$BRANCH" --base master --title "$title" --body "$body" | sed 's#.*/##')
fi
if [[ "$(gh pr view "$number" --json autoMergeRequest --jq '.autoMergeRequest == null')" == true ]]; then
gh pr merge "$number" --auto --squash
fi
gh workflow run ci.yml --ref "$BRANCH"
for _ in $(seq 80); do
state=$(gh pr view "$number" --json state,headRefOid --jq '"\(.state) \(.headRefOid)"')
[[ "$state" == "MERGED $PROPOSAL" ]] && exit 0
[[ "$state" == "OPEN $PROPOSAL" ]] || break
sleep 15
done
echo "Catalog proposal #$number is not merged ($state); the deployed site is retained." >&2
exit 1

- name: Verify merged catalog generation
if: steps.catalog.outputs.proposal != ''
env:
CATALOG_SOURCE_COMMIT: ${{ steps.source.outputs.commit }}
PROPOSAL: ${{ steps.catalog.outputs.proposal }}
run:
python3 scripts/plugin_catalog_publication.py --expected-head "$CATALOG_SOURCE_COMMIT"
--merged "$PROPOSAL"

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ verifies release archives without running package code. `npm run catalog:check-p
`npm run catalog:test` run offline; `npm run catalog:check` validates a generated snapshot.
Automatic publication requires a complete verified snapshot committed to `master`; a missing seed
fails Pages publication before upload and preserves the deployed site. Runs reconcile hourly, on
source pushes and on manual workflow dispatch, persist changed generations and deploy one complete
artifact. See [the plugin catalog contract](docs/plugin-catalog.md) for provenance, withdrawals,
resource bounds, atomic updates and installation trust.
source pushes and on manual workflow dispatch, merge changed generations through an auto-merged pull
request and deploy one complete artifact. See [the plugin catalog contract](docs/plugin-catalog.md)
for provenance, withdrawals, resource bounds, atomic updates and installation trust.

## Content boundaries

Expand Down
46 changes: 27 additions & 19 deletions docs/plugin-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,23 +162,30 @@ missed notifications and replaced pending runs are repaired by the next successf

After generation, the workflow validates and tests the complete site. The publication helper checks
the catalog successor, exact built index bytes and unchanged source, then commits only the new index
as a child of the checked-out commit. It uses a normal fast-forward push, never a force push. A
concurrent source update rejects publication; the next run starts from current `master`. Identical
catalog content creates no commit. Unrelated local or staged changes are rejected and preserved.

Only then does Pages upload and deploy the complete artifact. Generation, validation or commit
failure prevents upload and preserves the deployed artifact. If deployment fails after the verified
index commit, the next run uses that committed generation and can deploy it without incrementing
generation. A local generated file alone does not deploy anything. Publisher tests also run in
read-only website CI, without contacting release sources.

The central build job uses its short-lived GitHub job token with `contents: write` to persist the
index; the deployment job has Pages and identity-token permissions. Plugin notification senders
should invoke `pages.yml` on `master` using `workflow_dispatch`, with Actions write access scoped to
this receiving repository. They do not need website Contents write access. Notification payloads
provide no metadata or policy overrides. Existing GitHub authentication is an operator input; this
repository does not create credentials. If notifications are unavailable, scheduled and manual
reconciliation still use the same complete verification path.
as a child of the checked-out commit. It pushes that commit to `automation/plugin-catalog`,
replacing any earlier unmerged proposal, and `master` changes only through the resulting pull
request. The workflow opens or updates the pull request, enables squash auto-merge and dispatches
Website CI on the branch, because pull requests opened with the job token do not trigger workflows.
It then waits up to 20 minutes for the merge and requires `master` to be exactly the squash of the
proposal onto the checked-out commit. A concurrent source update rejects publication; the next run
starts from current `master`. Identical catalog content creates no proposal. Unrelated local or
staged changes are rejected and preserved.

Only then does Pages upload and deploy the complete artifact. Generation, validation, proposal or
merge failure prevents upload and preserves the deployed artifact. If the proposal merges after the
run stops waiting, or deployment fails after the merge, the next run uses that committed generation
and can deploy it without incrementing generation. A local generated file alone does not deploy
anything. Publisher tests also run in read-only website CI, without contacting release sources.

The repository allows auto-merge and lets GitHub Actions create pull requests. The central build job
uses its short-lived GitHub job token with contents, pull-request and Actions write access to push
the proposal branch, open and auto-merge its pull request and dispatch Website CI; the deployment
job has Pages and identity-token permissions. Plugin notification senders should invoke `pages.yml`
on `master` using `workflow_dispatch`, with Actions write access scoped to this receiving
repository. They do not need website Contents write access. Notification payloads provide no
metadata or policy overrides. Existing GitHub authentication is an operator input; this repository
does not create credentials. If notifications are unavailable, scheduled and manual reconciliation
still use the same complete verification path.

## Plugin release notifications

Expand Down Expand Up @@ -242,5 +249,6 @@ are generation failure bounds, not permission to truncate the catalog or omit ol
Tests cover provenance rejection, immutable identities, explicit withdrawal, failed writes,
concurrent publishers, duplicate events, invalid/oversized/truncated input, bounded retries,
manifest/archive agreement, safe manifest reads, credential-free redirects, committed generation
continuity, concurrent source pushes and deployment retry. Network discovery in the host remains a
separate consumer; installation must retain its exact GitHub revalidation.
continuity, proposal replacement, merge verification, concurrent source pushes and deployment retry.
Network discovery in the host remains a separate consumer; installation must retain its exact GitHub
revalidation.
53 changes: 38 additions & 15 deletions scripts/plugin_catalog_publication.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Commit one verified catalog before Pages upload, rejecting concurrent source updates."""
"""Propose one verified catalog for merge and confirm the merged source before Pages upload."""

import argparse
import os
Expand All @@ -16,6 +16,7 @@
INDEX = "public/plugins/index.json"
POLICY = "scripts/plugin-catalog-policy.json"
BRANCH = "refs/heads/master"
PROPOSAL = "refs/heads/automation/plugin-catalog"
REMOTE = "https://github.com/computer-mcp/computer-mcp.github.io"


Expand All @@ -27,10 +28,10 @@ def git(root, *arguments, data=None, environment=None):
return result.stdout


def remote_head(root):
fields = git(root, "ls-remote", "--exit-code", "origin", BRANCH).decode().split()
catalog.require(len(fields) == 2 and fields[1] == BRANCH
and re.fullmatch(r"[0-9a-f]{40}", fields[0]), "Invalid remote master identity")
def remote_head(root, ref=BRANCH):
fields = git(root, "ls-remote", "--exit-code", "origin", ref).decode().split()
catalog.require(len(fields) == 2 and fields[1] == ref
and re.fullmatch(r"[0-9a-f]{40}", fields[0]), f"Invalid remote {ref} identity")
return fields[0]


Expand All @@ -55,7 +56,7 @@ def verify_seed(root, expected_head):
return previous


def publish_commit(root, expected_head):
def propose_commit(root, expected_head):
catalog.require(re.fullmatch(r"[0-9a-f]{40}", expected_head) is not None, "Invalid source commit")
catalog.require(git(root, "rev-parse", "HEAD").decode().strip() == expected_head,
"Local source changed during publication")
Expand All @@ -76,7 +77,7 @@ def publish_commit(root, expected_head):
if current == previous:
return expected_head
# Construct the tree from the verified bytes and parent, without touching the caller's index
# or committing unrelated working-tree files. A concurrent remote child rejects this push.
# or committing unrelated working-tree files. Each run replaces any earlier unmerged proposal.
blob = git(root, "hash-object", "-w", "--stdin", data=data).decode().strip()
with tempfile.TemporaryDirectory(prefix="computer-mcp-catalog-index-") as directory:
environment = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")}
Expand All @@ -88,18 +89,34 @@ def publish_commit(root, expected_head):
"GIT_AUTHOR_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
"GIT_COMMITTER_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com"}
commit = git(root, "-c", "commit.gpgsign=false", "commit-tree", tree, "-p", expected_head,
"-m", f"chore: publish plugin catalog generation {current['generation']}",
"-m", f"Publish plugin catalog generation {current['generation']}",
environment=identity).decode().strip()
git(root, "push", "--porcelain", "origin", commit + ":" + BRANCH)
catalog.require(remote_head(root) == commit, "Catalog commit changed before Pages upload")
git(root, "push", "--porcelain", "--force", "origin", commit + ":" + PROPOSAL)
catalog.require(remote_head(root, PROPOSAL) == commit, "Catalog proposal changed before merge")
return commit


def verify_merged(root, expected_head, proposal):
for value in (expected_head, proposal):
catalog.require(re.fullmatch(r"[0-9a-f]{40}", value) is not None, "Invalid source commit")
git(root, "fetch", "--no-tags", "origin", BRANCH)
merged = git(root, "rev-parse", "FETCH_HEAD").decode().strip()
catalog.require(merged != expected_head, "Catalog proposal is not merged")
catalog.require(git(root, "rev-list", "--parents", "-n", "1", merged).decode().split()
== [merged, expected_head],
"Master changed before the catalog proposal merged; reconcile from current master")
catalog.require(git(root, "rev-parse", merged + "^{tree}") == git(root, "rev-parse", proposal + "^{tree}"),
"Merged source differs from the verified catalog proposal")
return merged


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--expected-head", required=True)
parser.add_argument("--check-seed", action="store_true",
help="Verify committed seed before reconciliation, without publication")
parser.add_argument("--merged", metavar="PROPOSAL",
help="Verify that master is the squash merge of this catalog proposal")
args = parser.parse_args()
try:
catalog.require(os.environ.get("GITHUB_ACTIONS") == "true"
Expand All @@ -112,11 +129,17 @@ def main():
snapshot = verify_seed(ROOT, args.expected_head)
print(f"Verified committed catalog seed: generation {snapshot['generation']}")
return
commit = publish_commit(ROOT, args.expected_head)
if output := os.environ.get("GITHUB_OUTPUT"):
with open(output, "a", encoding="utf-8") as file:
file.write(f"commit={commit}\n")
print(f"Verified catalog source for Pages: {commit}")
if args.merged:
commit = verify_merged(ROOT, args.expected_head, args.merged)
output, message = f"commit={commit}", f"Verified merged catalog source for Pages: {commit}"
elif (commit := propose_commit(ROOT, args.expected_head)) == args.expected_head:
output, message = f"commit={commit}", f"Verified catalog source for Pages: {commit}"
else:
output, message = f"proposal={commit}", f"Proposed catalog generation for merge: {commit}"
if path := os.environ.get("GITHUB_OUTPUT"):
with open(path, "a", encoding="utf-8") as file:
file.write(output + "\n")
print(message)
except (catalog.CatalogError, OSError, ValueError, subprocess.SubprocessError) as error:
message = str(error) if isinstance(error, catalog.CatalogError) else type(error).__name__
print(f"Catalog publication failed: {message}", file=sys.stderr)
Expand Down
Loading
Loading