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