diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 3d09624..a87b0e7 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -19,7 +19,39 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: + # WHY A GITHUB APP AND NOT github.token + # ------------------------------------- + # The org policy "Allow GitHub Actions to create and approve pull requests" is OFF + # org-wide, and the repo-level toggle is overridden by it (setting it returns 409). + # So the default GITHUB_TOKEN cannot open the sync PR — every run since 2026-07-03 + # died on `GraphQL: GitHub Actions is not permitted to create ... (createPullRequest)`. + # Rather than loosen that control for EVERY repo in the org (which would also let any + # workflow self-APPROVE PRs), this workflow mints a short-lived token from a GitHub + # App scoped to this repo alone with `pull-requests: write` + `contents: write`. + # Setup is one-time and documented in docs/runbooks/sync-upstream-app.md. + - name: Preflight — require the sync App credentials + env: + APP_ID: ${{ secrets.SYNC_APP_ID }} + APP_KEY: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "${APP_ID:-}" ] || [ -z "${APP_KEY:-}" ]; then + echo "::error title=Sync App not configured::SYNC_APP_ID and/or SYNC_APP_PRIVATE_KEY are unset. This workflow authenticates PR creation via a scoped GitHub App because the org blocks the default GITHUB_TOKEN from creating PRs. One-time setup: docs/runbooks/sync-upstream-app.md" + exit 1 + fi + + - name: Mint a scoped App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + + # Checked out WITH the App token so the later `git push` is authenticated as the App + # (not the default GITHUB_TOKEN), which is also what lets CI run on the resulting PR. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + token: ${{ steps.app-token.outputs.token }} - name: Install uv uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 @@ -49,7 +81,7 @@ jobs: if: steps.drift.outputs.needs_bump == 'true' id: existing env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} # Via env, NEVER interpolated into the script body. `to_version` originates # OUTSIDE this repo — scripts/refresh-upstream.sh reads it from the upstream # release feed — so a version string carrying shell metacharacters would be @@ -163,7 +195,7 @@ jobs: && steps.patch_attempt.outputs.exit_code == '0' && steps.tests.outputs.exit_code == '0' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} FROM: ${{ steps.drift.outputs.from_version }} TO: ${{ steps.drift.outputs.to_version }} BRANCH: ${{ steps.existing.outputs.branch }} @@ -192,7 +224,7 @@ jobs: && steps.existing.outputs.skip != 'true' && steps.patch_attempt.outputs.exit_code != '0' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} FROM: ${{ steps.drift.outputs.from_version }} TO: ${{ steps.drift.outputs.to_version }} BRANCH: ${{ steps.existing.outputs.branch }} @@ -232,7 +264,7 @@ jobs: && steps.patch_attempt.outputs.exit_code == '0' && steps.tests.outputs.exit_code != '0' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} FROM: ${{ steps.drift.outputs.from_version }} TO: ${{ steps.drift.outputs.to_version }} BRANCH: ${{ steps.existing.outputs.branch }} diff --git a/docs/runbooks/sync-upstream-app.md b/docs/runbooks/sync-upstream-app.md new file mode 100644 index 0000000..669af6b --- /dev/null +++ b/docs/runbooks/sync-upstream-app.md @@ -0,0 +1,106 @@ +# Runbook — scoped GitHub App for the upstream-sync workflow + +`.github/workflows/sync-upstream.yml` opens a PR when a newer `latitudesh-python-sdk` +release is detected. Opening a PR from a workflow requires a token that is allowed to +create pull requests. The org policy **"Allow GitHub Actions to create and approve pull +requests" is OFF** org-wide, and the per-repo toggle is overridden by it (the API returns +`409 Conflict` when you try to set it on the repo). + +Rather than turn that control on for **every** repo in the org — which would also let any +workflow anywhere self-**approve** PRs — this workflow authenticates with a **GitHub App +scoped to this repository only**. This is the least-privilege option: org policy stays +locked, no other repo gains anything. + +This is a **one-time** setup. Until it is done, the workflow fails fast at its +`Preflight — require the sync App credentials` step with a link back here. + +--- + +## What you create + +A GitHub App owned by the `Digital-Frontier-LDA` org, installed on **`edge-python-sdk` +only**, with exactly two repository permissions: + +| Permission | Level | Why | +|---|---|---| +| Pull requests | Read and write | open the sync PR | +| Contents | Read and write | push the `sync/upstream-` branch | + +Nothing else. No org permissions, no account permissions, no webhook. + +## Steps + +1. **Create the App** + `https://github.com/organizations/Digital-Frontier-LDA/settings/apps` → **New GitHub App**. + - **Name:** `df-edge-sdk-sync` (any unique name) + - **Homepage URL:** the repo URL is fine + - **Webhook:** *uncheck* "Active" — this App is pull-only, it receives nothing + - **Repository permissions:** set **Pull requests → Read and write** and + **Contents → Read and write**. Leave everything else "No access". + - **Where can this App be installed:** "Only on this account" + - Create. + +2. **Note the App ID** — shown on the App's settings page (a number). This becomes the + `SYNC_APP_ID` secret. + +3. **Generate a private key** — on the App page, "Private keys" → **Generate a private + key**. A `.pem` downloads. Its full contents become the `SYNC_APP_PRIVATE_KEY` secret. + +4. **Install the App on the repo** — App page → **Install App** → install into + `Digital-Frontier-LDA` → **Only select repositories** → pick **`edge-python-sdk`** → + Install. + +5. **Set the two repo secrets** + `https://github.com/Digital-Frontier-LDA/edge-python-sdk/settings/secrets/actions`, or: + + ```bash + # App ID (the number from step 2) + gh secret set SYNC_APP_ID --repo Digital-Frontier-LDA/edge-python-sdk --body '123456' + + # the whole .pem, newlines and all + gh secret set SYNC_APP_PRIVATE_KEY --repo Digital-Frontier-LDA/edge-python-sdk < ~/Downloads/df-edge-sdk-sync.*.private-key.pem + ``` + +6. **Verify** — trigger the workflow manually and watch it get past preflight: + + ```bash + gh workflow run "Sync upstream" --repo Digital-Frontier-LDA/edge-python-sdk --ref main + gh run watch "$(gh run list --repo Digital-Frontier-LDA/edge-python-sdk -w 'Sync upstream' -L1 --json databaseId -q '.[0].databaseId')" + ``` + + A green run either opens a `sync: bump upstream to ` PR, or reports "Already pinned + to latest — no PR needed." Either is success. A red run at **Preflight** means a secret + is unset; a red run at **Mint a scoped App token** means the App is not installed on + this repo or the key is malformed. + +## How the workflow uses it + +```yaml +- name: Mint a scoped App token + id: app-token + uses: actions/create-github-app-token@… # SHA-pinned + with: + app-id: ${{ secrets.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + +- uses: actions/checkout@… # SHA-pinned + with: + token: ${{ steps.app-token.outputs.token }} # so `git push` is the App, not GITHUB_TOKEN +``` + +The minted token lives only for the run and is scoped to this repo's two permissions. +Checking out **with** it means the later `git push` is authenticated as the App, which is +also what allows CI to run on the resulting PR (pushes made with the default +`GITHUB_TOKEN` deliberately do not trigger `on: pull_request`). + +## Rotating the key + +Generate a new private key on the App page, update `SYNC_APP_PRIVATE_KEY`, then delete the +old key from the App. The App ID does not change. + +## Why not just flip the org setting + +It is reversible and would work, but it grants **create *and approve*** to every workflow +in every repo in the org — including the money-handling ones. Weakening org-wide +review-gate integrity to serve one dependency-bump sync is disproportionate. This App +buys the same capability for one repo and nothing else.