From dc0db80e1bb26f0ff48d977eaf3668df6fa65dc9 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 17 Sep 2026 15:50:15 -0600 Subject: [PATCH] Reconcile the project board on a schedule, under a GitHub App #104 added a `board` step to the Phase 2 executor and deliberately left it as a maintainer-run command, because the only way to automate it then was a personal access token with organization-wide project write sitting in a repository secret. That commit named a GitHub App as the right long-term answer and separate work. This is that work. ## Why now The board drifted for seven days. Every issue and pull request from #105 onward, 40 items, was never added: the external conformance reports (#109, #111, #113), the second independent implementation (#106), the whole accepted 0.1.3 set (#132 through #136), and every AGT reference implementation bug an outside contributor filed. A stale board looks exactly like a current one, so nobody noticed until someone went looking for an issue they had just filed. The cause was narrower than it appeared. Project 9 has "Item added to project" enabled, which is why all 58 items carried a Status and none were blank. What it did not have was any auto-add for this repository. The board set Status correctly and never added anything. A dry run of the reconciler put the split at 40 adds against 1 Status change. ## What keeps it current Two things, and the real-time half needs no credential. The project's own "Auto-add to project" workflow adds each issue and pull request when it opens, and "Item added to project" gives it a Status. This workflow is the safety net under that. Nightly, it adds anything auto-add missed and corrects the Status of anything whose labels moved. The reconciler only adds an item or changes its Status field and never archives or removes one, which is the property that makes an unattended run acceptable. ## The grant organization_projects: write, plus read on this repository's issues and pull requests. A PAT with `project` scope would carry write on every project in the organization for as long as it lived; the App reaches boards and nothing else, and the job holds a one-hour installation token rather than the key that mints it. The key sits in the `board-automation` environment, not in repository secrets, with its deployment branch rule limited to the default branch, so a pull request branch cannot read it. Landing a malicious workflow on the default branch remains a path and is the one CODEOWNERS already guards: `/.github/` carries a narrower owner list than the default and the ruleset on main requires a code owner review. Signed-off-by: Rock Lambros --- .github/workflows/board-reconcile.yml | 117 ++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/board-reconcile.yml diff --git a/.github/workflows/board-reconcile.yml b/.github/workflows/board-reconcile.yml new file mode 100644 index 0000000..5130573 --- /dev/null +++ b/.github/workflows/board-reconcile.yml @@ -0,0 +1,117 @@ +# The org project board is the view triage actually runs from, and nothing in the +# repository could write to it until this workflow existed. +# +# Project 9 is organization-owned, so a workflow's GITHUB_TOKEN cannot touch it no +# matter what `permissions:` says. That is why tools/apply_governance.py documents +# itself as running under a maintainer's own credentials, and why the board drifted +# for seven days in September 2026: 40 issues and pull requests from #105 onward were +# never added, including every external conformance report and the whole accepted +# 0.1.3 set. Nobody noticed, because a board that is stale looks exactly like a board +# that is current. +# +# Two things keep it current now, and this is the second of them. +# +# The project's own "Auto-add to project" workflow adds new issues and pull requests +# the moment they are opened, and the already-enabled "Item added to project" workflow +# gives each one a Status. That is the real-time path and it needs no credential. +# +# This workflow is the safety net under it. It runs nightly, adds anything auto-add +# missed, and corrects the Status of anything whose labels moved since. The reconciler +# only adds an item or changes its Status field. It never archives or removes one, so +# an unattended run cannot destroy board state. That property is what makes running it +# on a schedule acceptable at all. +# +# No step interpolates a GitHub expression into a shell command. Every value reaches +# `run:` through `env:`, so a future edit that adds an event payload field cannot turn +# this into a workflow-injection surface. +# +# ## Why this is a workflow when #104 decided it should not be +# +# #104 rejected a workflow and was right to, on the reasoning that making one work +# "means storing a personal access token with organization-wide project write as a +# repository secret, in a repository that opened to a hundred contributors this +# morning, where anyone who can land a workflow file can reach it." That commit named +# a GitHub App as "the right long-term answer and is separate work." This is that work. +# +# An App narrows the grant and an environment narrows who can spend it. +# +# The grant is organization_projects: write plus read on this repository's issues and +# pull requests. A classic PAT with `project` scope would carry write on every project +# the granting user can see, across the whole organization, for as long as the token +# lives. The App reaches project boards and nothing else, and what this job holds is a +# one-hour installation token rather than the key that mints it. +# +# The private key lives in the `board-automation` environment rather than in repository +# secrets, with its deployment branch rule limited to the default branch. A workflow on +# a pull request branch, including one from a fork, cannot read it. Landing a malicious +# workflow on the default branch is still a path, and it is the path CODEOWNERS already +# guards: `/.github/` carries a narrower owner list than the repository default and the +# ruleset on main requires a code owner review. Two controls rather than one, and the +# residual risk is stated rather than assumed away. +name: Reconcile project board + +on: + schedule: + # 04:40 UTC. Off the hour because GitHub's scheduler queues heavily on the hour, + # and after the promotion window so the board reflects a merged integration. + - cron: "40 4 * * *" + workflow_dispatch: + inputs: + apply: + description: "Apply the changes. Leave false to print the plan without running it." + type: boolean + default: false + +permissions: {} + +jobs: + reconcile: + runs-on: ubuntu-latest + # The reconciler makes one API call per drifted item and the board carries roughly + # a hundred. A normal night is a handful of calls; a backfill after an outage is + # the case this limit is sized for. + timeout-minutes: 15 + permissions: + contents: read + # Holds the App private key. Its deployment branch rule restricts it to the default + # branch, so a workflow running on a pull request branch cannot read the key even if + # the workflow file itself asks for this environment. + environment: board-automation + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Only tools/apply_governance.py is read from the checkout. Every fact the + # reconciler acts on is fetched live from the API, so the checked-out ref + # decides which version of the tool runs and nothing else. + persist-credentials: false + + - name: Mint an installation token for the organization + id: token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.BOARD_APP_ID }} + private-key: ${{ secrets.BOARD_APP_PRIVATE_KEY }} + # Without an owner the token is scoped to this repository alone and cannot + # reach an organization-owned project. The App is installed with + # organization_projects: write plus read on this repository's issues and + # pull requests, which is the whole grant. It cannot write code, change + # settings, or reach any other repository. + owner: ${{ github.repository_owner }} + + - name: Reconcile the board + env: + # apply_governance.py shells out to `gh`, which reads GH_TOKEN. This is an + # App installation token valid for one hour, not a stored credential. + GH_TOKEN: ${{ steps.token.outputs.token }} + # A scheduled run always applies. A manual run defaults to printing the plan + # so a maintainer can read what would change before letting it change. + APPLY: ${{ github.event_name == 'schedule' && 'true' || inputs.apply }} + run: | + set -euo pipefail + if [ "$APPLY" = "true" ]; then + python3 tools/apply_governance.py --only board --apply + else + echo "Dry run. Pass apply=true to run these commands." + python3 tools/apply_governance.py --only board + fi