From c87a6b233e4d3730693d9c5e9b9445ce91df6242 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:12:09 +0100 Subject: [PATCH 1/2] ci(roadmap-sync): mint a GitHub App token and sync into the metadatastician org project The classic PAT ADD_TO_PROJECT_PAT cannot be replaced by a fine-grained token: fine-grained PATs and GitHub Apps carry a Projects permission only for an organization, and Project #35 is user-owned. #35 is therefore copied to metadatastician/projects/2 (same fields, options, views and workflows) and the sweeper now mints a per-run installation token with actions/create-github-app-token (org projects: write; issues and metadata: read) and writes to that project while still reading the public hyperpolymath repos. Preflight asserts that the project id is an org id (PVT_kwDO...) and that the token can read a public hyperpolymath repo, failing loudly otherwise so a startup-dead run cannot pass as green. actions.lock: the one new dependency was onboarded with a targeted `gh actions-lock .github/workflows/roadmap-sync.yml`; the 23 pre-existing findings in other workflows are untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011qEporYjgWy2Kw6WDQismf --- .github/workflows/actions.lock | 8 ++- .github/workflows/roadmap-sync.yml | 100 +++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 29 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 62a9b364..58b14d22 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -85,7 +85,8 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'softprops/action-gh-release@v3.0.3' - 'swatinem/rust-cache@v2.9.2' - '.github/workflows/roadmap-sync.yml': [] + '.github/workflows/roadmap-sync.yml': + - 'actions/create-github-app-token@v3.2.0' '.github/workflows/rust.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' @@ -159,6 +160,11 @@ dependencies: commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 + 'actions/create-github-app-token@v3.2.0': + ref: 'v3.2.0' + commit: 'sha1-bcd2ba49218906704ab6c1aa796996da409d3eb1' + owner_id: 44036562 + repo_id: 642580244 'actions/deploy-pages@v5.0.1': ref: 'v5.0.1' commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' diff --git a/.github/workflows/roadmap-sync.yml b/.github/workflows/roadmap-sync.yml index 5afaab76..9fce50cd 100644 --- a/.github/workflows/roadmap-sync.yml +++ b/.github/workflows/roadmap-sync.yml @@ -2,23 +2,33 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Centralized roadmap sweeper: adds recently-touched issues & PRs from every -# owned repo (except son-shared) into the Hyperpolymath Master Roadmap (#35). +# public repo owned by REPO_OWNER (except son-shared) into the org-owned +# "Hyperpolymath Master Scheduler" project (PROJECT_OWNER / PROJECT_NUMBER). # -# WHY centralized (one workflow, one secret) instead of a per-repo workflow: -# - the PAT secret lives in exactly ONE repo (this one), not ~250 copies; -# - one file to maintain, one place to rotate the token. -# GITHUB_TOKEN is scoped to this repo only and cannot read other repos, so all -# cross-repo reads use the classic PAT (ADD_TO_PROJECT_PAT: scopes project + -# public_repo). Private repos are therefore NOT covered yet — widening the PAT -# to `repo` (one secret, one place) is a deliberate later step. -name: Roadmap Sync (#35) +# WHY centralized (one workflow, one credential) instead of a per-repo workflow: +# - the credential lives in exactly ONE repo (this one), not ~250 copies; +# - one file to maintain, one place to rotate. +# CREDENTIAL: a GitHub App owned by the project's org. Every run mints a <=1 h +# installation token (Org: Projects read/write; Repo: Issues read, Metadata read) +# from the App's private key (secret ROADMAP_SYNC_APP_PRIVATE_KEY) and client id +# (variable ROADMAP_SYNC_APP_CLIENT_ID). No PAT; the key is the only long-lived +# material. GITHUB_TOKEN is unused (permissions: {}). +# COVERAGE: public repos only. Private repos become visible by installing the +# same App on REPO_OWNER with Issues: read and minting a second token — a +# deliberate later step. +name: Roadmap Sync on: schedule: - - cron: '*/30 * * * *' # every 30 min (public repo → free minutes; adjust freely) - workflow_dispatch: # manual on-demand run + - cron: '*/30 * * * *' # every 30 min; the window below overlaps it + workflow_dispatch: + inputs: + window_min: + description: 'look-back window in minutes' + required: false + default: '45' -permissions: {} # GITHUB_TOKEN unused; all work goes through the PAT +permissions: {} # GITHUB_TOKEN unused; all work goes through the App token concurrency: group: roadmap-sync @@ -29,33 +39,65 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 env: - GH_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} - PROJECT_OWNER: hyperpolymath - PROJECT_NUMBER: '35' + REPO_OWNER: hyperpolymath # whose public repos are swept + PROJECT_OWNER: metadatastician # who owns the project + PROJECT_NUMBER: '2' # son-shared repos — excluded per estate boundaries (AGPL, son's work) EXCLUDE: 'idaptik burble rattlescript vcl-ut' - WINDOW_MIN: '45' # look-back window (> cron interval for overlap) + WINDOW_MIN: ${{ inputs.window_min || '45' }} # look-back window (> cron interval for overlap) steps: - - name: Sweep recent issues/PRs into roadmap #35 + - name: Mint App installation token + id: app + uses: actions/create-github-app-token@v3.2.0 + with: + client-id: ${{ vars.ROADMAP_SYNC_APP_CLIENT_ID }} + private-key: ${{ secrets.ROADMAP_SYNC_APP_PRIVATE_KEY }} + owner: metadatastician # the installation's owner, not this repo's + permission-organization-projects: write + permission-issues: read + permission-metadata: read + + - name: Preflight (positive control, then the cross-owner read) + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + set -euo pipefail + echo "token sees org: $(gh api "orgs/$PROJECT_OWNER" --jq .login)" + PID=$(gh api graphql \ + -f query='query($o:String!,$n:Int!){organization(login:$o){projectV2(number:$n){id}}}' \ + -f o="$PROJECT_OWNER" -F n="$PROJECT_NUMBER" --jq '.data.organization.projectV2.id') + case "$PID" in + PVT_kwDO*) echo "org project id: $PID" ;; + *) echo "::error::not an org project id: '$PID' (check PROJECT_NUMBER)"; exit 1 ;; + esac + if ! n=$(gh api "repos/$REPO_OWNER/hypatia/issues?state=open&per_page=1" --jq length); then + echo "::error::installation token cannot read public $REPO_OWNER repos; switch to the fine-grained PAT fallback" + exit 1 + fi + echo "cross-owner read ok ($n item)" + + - name: Sweep recent issues/PRs into the roadmap + env: + GH_TOKEN: ${{ steps.app.outputs.token }} run: | set -euo pipefail SINCE=$(date -u -d "${WINDOW_MIN} minutes ago" +%Y-%m-%dT%H:%M:%SZ) echo "::group::Setup" echo "Window since: $SINCE" PID=$(gh api graphql \ - -f query='query($o:String!,$n:Int!){user(login:$o){projectV2(number:$n){id title}}}' \ - -f o="$PROJECT_OWNER" -F n="$PROJECT_NUMBER" --jq '.data.user.projectV2.id') + -f query='query($o:String!,$n:Int!){organization(login:$o){projectV2(number:$n){id title}}}' \ + -f o="$PROJECT_OWNER" -F n="$PROJECT_NUMBER" --jq '.data.organization.projectV2.id') echo "Project node id: $PID" echo "::endgroup::" - # Owned, non-fork, non-archived repos the PAT can see (public with the - # current token scope). Excludes are skipped below. + # Public, non-fork, non-archived repos owned by REPO_OWNER. The App is + # not installed there, so only public repos are visible (see header). mapfile -t REPOS < <(gh api --paginate \ - '/user/repos?affiliation=owner&per_page=100' \ + "users/$REPO_OWNER/repos?type=owner&per_page=100" \ --jq '.[] | select(.fork==false and .archived==false) | .name') echo "Visible owned repos: ${#REPOS[@]}" - scanned=0; items=0; adds=0 + scanned=0; items=0; adds=0; unreadable=0 for r in "${REPOS[@]}"; do skip=0 for x in $EXCLUDE; do [ "$r" = "$x" ] && skip=1 && break; done @@ -64,9 +106,11 @@ jobs: # The issues endpoint returns BOTH issues and PRs, filtered by # updated_at >= since. node_id works for either content type. - NODES=$(gh api --paginate \ - "repos/$PROJECT_OWNER/$r/issues?state=open&since=$SINCE&per_page=100" \ - --jq '.[].node_id' 2>/dev/null || true) + if ! NODES=$(gh api --paginate \ + "repos/$REPO_OWNER/$r/issues?state=open&since=$SINCE&per_page=100" \ + --jq '.[].node_id'); then + unreadable=$((unreadable+1)); echo "::warning::could not list issues of $r"; continue + fi for nid in $NODES; do [ -z "$nid" ] && continue items=$((items+1)) @@ -83,6 +127,6 @@ jobs: done echo "----------------------------------------" - echo "Repos scanned: $scanned (excluded: $EXCLUDE)" + echo "Repos scanned: $scanned (excluded: $EXCLUDE; unreadable: $unreadable)" echo "Recent items seen: $items | add-calls ok: $adds (idempotent)" - echo "NOTE: private repos are not covered until ADD_TO_PROJECT_PAT gains 'repo' scope." + echo "NOTE: private repos are not covered until the App is installed on $REPO_OWNER." From b061c30c03abb475f538289cefeb80122e8ffb16 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:25:13 +0100 Subject: [PATCH 2/2] fix(roadmap-sync): fail on a failed repo listing; probe a listed repo Address CodeRabbit review on #903: capture the exit status of the paginated repo listing (a process substitution hid it from set -e), and make the preflight probe the first public repo the sweep would read instead of a hardcoded one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qEporYjgWy2Kw6WDQismf --- .github/workflows/roadmap-sync.yml | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/roadmap-sync.yml b/.github/workflows/roadmap-sync.yml index 9fce50cd..d3b165aa 100644 --- a/.github/workflows/roadmap-sync.yml +++ b/.github/workflows/roadmap-sync.yml @@ -70,11 +70,17 @@ jobs: PVT_kwDO*) echo "org project id: $PID" ;; *) echo "::error::not an org project id: '$PID' (check PROJECT_NUMBER)"; exit 1 ;; esac - if ! n=$(gh api "repos/$REPO_OWNER/hypatia/issues?state=open&per_page=1" --jq length); then - echo "::error::installation token cannot read public $REPO_OWNER repos; switch to the fine-grained PAT fallback" + # Probe a repo the sweep itself would read: the first public repo listed. + if ! probe=$(gh api "users/$REPO_OWNER/repos?type=owner&per_page=1" --jq '.[0].name // empty') \ + || [ -z "$probe" ]; then + echo "::error::installation token cannot list public $REPO_OWNER repos; switch to the fine-grained PAT fallback" exit 1 fi - echo "cross-owner read ok ($n item)" + if ! n=$(gh api "repos/$REPO_OWNER/$probe/issues?state=open&per_page=1" --jq length); then + echo "::error::installation token cannot read issues of public repo $REPO_OWNER/$probe; switch to the fine-grained PAT fallback" + exit 1 + fi + echo "cross-owner read ok ($REPO_OWNER/$probe, $n item)" - name: Sweep recent issues/PRs into the roadmap env: @@ -92,9 +98,18 @@ jobs: # Public, non-fork, non-archived repos owned by REPO_OWNER. The App is # not installed there, so only public repos are visible (see header). - mapfile -t REPOS < <(gh api --paginate \ + # Capture the listing's exit status: a process substitution would hide a + # failed or partial listing from set -e and let the sweep "succeed". + if ! REPO_LIST=$(gh api --paginate \ "users/$REPO_OWNER/repos?type=owner&per_page=100" \ - --jq '.[] | select(.fork==false and .archived==false) | .name') + --jq '.[] | select(.fork==false and .archived==false) | .name'); then + echo "::error::could not list repositories for $REPO_OWNER" + exit 1 + fi + REPOS=() + if [ -n "$REPO_LIST" ]; then + mapfile -t REPOS <<< "$REPO_LIST" + fi echo "Visible owned repos: ${#REPOS[@]}" scanned=0; items=0; adds=0; unreadable=0