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
30 changes: 23 additions & 7 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
actions: write
contents: write
contents: read
pages: read
pull-requests: write

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

- name: Detect catalog change
id: generation
run: |
if ! git diff --quiet HEAD -- public/plugins/index.json; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

# Pull requests opened with the job token wait for manual approval before their checks run,
# so a catalog proposal and its pull request use the automation App's installation token.
- name: Create automation token
id: automation
if: steps.generation.outputs.changed == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.AUTOMATION_APP_CLIENT_ID }}
private-key: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

- name: Prepare verified catalog generation
id: catalog
env:
CATALOG_SOURCE_COMMIT: ${{ steps.source.outputs.commit }}
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.automation.outputs.token }}
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 }}
GH_TOKEN: ${{ steps.automation.outputs.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."
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. The pull request merges automatically once Website CI 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"
Expand All @@ -111,7 +128,6 @@ jobs:
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
Expand Down
40 changes: 22 additions & 18 deletions docs/plugin-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,31 +164,35 @@ After generation, the workflow validates and tests the complete site. The public
the catalog successor, exact built index bytes and unchanged source, then computes a commit tree
that changes only the new index on top of the checked-out commit. Because `master` accepts only
signed commits, the helper has GitHub create that blob, tree and commit through the REST API with
the job token, which GitHub signs. It requires GitHub's objects to match the locally computed tree
and parent and the commit to be verified. It then points `automation/plugin-catalog` at that commit,
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.
the automation App's installation token, which GitHub signs. It requires GitHub's objects to match
the locally computed tree and parent and the commit to be verified. It then points
`automation/plugin-catalog` at that commit, replacing any earlier unmerged proposal, and `master`
changes only through the resulting pull request. The workflow opens or updates the pull request with
the same token and enables squash auto-merge, so Website CI runs on it like any other pull request;
a pull request opened with the job token would wait for manual approval instead. 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 create
the signed proposal, 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.
The repository allows auto-merge. Only when the catalog changes, the central build job mints a
short-lived installation token for the organization's Computer MCP Automation GitHub App, which has
Contents and Pull requests write access and is installed only on this repository and the Homebrew
tap. Its client ID and private key are the `AUTOMATION_APP_CLIENT_ID` variable and
`AUTOMATION_APP_PRIVATE_KEY` secret. That token creates the signed proposal and opens and
auto-merges its pull request; the job token stays read-only, and 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
6 changes: 3 additions & 3 deletions scripts/plugin_catalog_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def redirect_request(self, *arguments):

def rest(method, path, body):
token = os.environ.get("GH_TOKEN")
catalog.require(bool(token), "Catalog proposal requires the workflow token")
catalog.require(bool(token), "Catalog proposal requires the automation App token")
request = urllib.request.Request(API + path, method=method, data=json.dumps(body).encode(), headers={
"Accept": "application/vnd.github+json", "Authorization": "Bearer " + token,
"Content-Type": "application/json", "User-Agent": "computer-mcp-plugin-catalog/1",
Expand Down Expand Up @@ -108,8 +108,8 @@ def propose_commit(root, expected_head):
return expected_head
# Compute the expected tree from the verified bytes and parent without touching the caller's
# index. GitHub then creates the same objects, because master accepts only signed commits and
# GitHub signs commits that the job token creates through its API. Each run replaces any
# earlier unmerged proposal.
# GitHub signs commits that the automation App's token creates through its API. 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 Down
2 changes: 1 addition & 1 deletion scripts/test_plugin_catalog_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def test_unsigned_proposal_is_not_published(self):
self.assertEqual(self.proposals(), [])
self.assertEqual(publication.remote_head(self.root), self.head)

def test_proposal_requires_the_workflow_token(self):
def test_proposal_requires_the_automation_token(self):
with patch.dict(os.environ, {"GH_TOKEN": ""}), self.assertRaisesRegex(catalog.CatalogError, "token"):
REST("POST", "/git/blobs", {})

Expand Down
Loading