Skip to content
Merged
Show file tree
Hide file tree
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
8 changes: 7 additions & 1 deletion .github/workflows/actions.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
119 changes: 89 additions & 30 deletions .github/workflows/roadmap-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,33 +39,80 @@
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 }}
Comment thread
hyperpolymath marked this conversation as resolved.
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
# 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
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:
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.
mapfile -t REPOS < <(gh api --paginate \
'/user/repos?affiliation=owner&per_page=100' \
--jq '.[] | select(.fork==false and .archived==false) | .name')
# Public, non-fork, non-archived repos owned by REPO_OWNER. The App is
# not installed there, so only public repos are visible (see header).
# 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'); 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
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
Expand All @@ -64,9 +121,11 @@

# 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))
Expand All @@ -83,6 +142,6 @@
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."
Loading