From 8667f4b07c9bd59ae8c3a4abc625e89c6da99709 Mon Sep 17 00:00:00 2001 From: show <10173746+showxu@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:28:14 +0800 Subject: [PATCH] Open catalog proposals with the automation App GitHub now holds pull_request runs on pull requests opened with the job token until someone approves them, and checks from the dispatched Website CI run never satisfy the required checks, so a catalog proposal could not merge on its own. When the catalog changes, the Pages job now mints a Contents and Pull requests installation token from the organization's Computer MCP Automation App and uses it to create the signed proposal and its pull request, whose Website CI then runs on its own. The job token becomes read-only and the dispatch step is removed. --- .github/workflows/pages.yml | 30 ++++++++++++---- docs/plugin-catalog.md | 40 ++++++++++++---------- scripts/plugin_catalog_publication.py | 6 ++-- scripts/test_plugin_catalog_publication.py | 2 +- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ddcc434..399caa1 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 @@ -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" @@ -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 diff --git a/docs/plugin-catalog.md b/docs/plugin-catalog.md index 5f91537..5e991fb 100644 --- a/docs/plugin-catalog.md +++ b/docs/plugin-catalog.md @@ -164,15 +164,16 @@ 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 @@ -180,15 +181,18 @@ run stops waiting, or deployment fails after the merge, the next run uses that c 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 diff --git a/scripts/plugin_catalog_publication.py b/scripts/plugin_catalog_publication.py index 872ca68..f7b48ba 100644 --- a/scripts/plugin_catalog_publication.py +++ b/scripts/plugin_catalog_publication.py @@ -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", @@ -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")} diff --git a/scripts/test_plugin_catalog_publication.py b/scripts/test_plugin_catalog_publication.py index 91c2b67..47edba7 100644 --- a/scripts/test_plugin_catalog_publication.py +++ b/scripts/test_plugin_catalog_publication.py @@ -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", {})