Skip to content
Merged
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
117 changes: 117 additions & 0 deletions .github/workflows/board-reconcile.yml
Original file line number Diff line number Diff line change
@@ -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