Cross-Repo Stub Drift Token — Provisioning & Rotation
Status: active — 2026-04-22.
Owner: devops-engineer.
Related: core#851, client#111.
Purpose
upsquad-client and upsquad-admin both consume Connect-ES stubs
generated from upsquad-core protos. A Stub Drift Check workflow in
each consumer repo re-runs stub generation against the current
upsquad-core@main proto surface and fails the PR if src/gen/**
drifts from what would be re-generated.
To read the core proto tree, that workflow needs a read-only
credential scoped to upsquad-ai/upsquad-core contents + metadata.
This runbook documents how that credential is provisioned, how it is
consumed, and how to rotate it.
Credential model
Do not use a long-lived personal access token.
The drift workflow authenticates as the upsquad-devops-engineer
GitHub App (App ID tracked in scripts/gh-token.py). The App is
already installed on all four upsquad-ai repos with the minimum
scope set (contents:read, metadata:read). A short-lived
installation token is minted per workflow run.
Two repository secrets on each consumer repo:
| Secret | Value | Rotates |
|---|---|---|
CORE_REPO_APP_ID | Numeric App ID of upsquad-devops-engineer | On App rotation only (rare) |
CORE_REPO_APP_PRIVATE_KEY | PEM-encoded private key for the App | Every 90 days |
The workflow step that mints the token is:
- name: Mint core-repo installation token
id: core_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CORE_REPO_APP_ID }}
private-key: ${{ secrets.CORE_REPO_APP_PRIVATE_KEY }}
owner: upsquad-ai
repositories: upsquad-core
The token is then consumed as token: ${{ steps.core_token.outputs.token }}
on the actions/checkout@v4 step that pulls upsquad-ai/upsquad-core.
Provisioning
Performed by a devops-engineer bot session with
/opt/upsquad-keys/ group access. Run once per consumer repo:
# Export the raw private key (lives on disk under /opt/upsquad-keys/).
KEY_PATH=/opt/upsquad-keys/devops-engineer.private-key.pem
# App ID — read from scripts/gh-token.py's APP_IDS table.
APP_ID=$(python3 -c "from pathlib import Path; import re; \
s=Path('/opt/upsquad/upsquad-core/scripts/gh-token.py').read_text(); \
print(re.search(r\"devops-engineer.*?(\\d+)\", s).group(1))")
# Authenticate as the devops-engineer bot on the consumer repo.
sg upsquad-devs -c 'GH_TOKEN=$(python3 /opt/upsquad/upsquad-core/scripts/gh-token.py devops-engineer) \
gh secret set CORE_REPO_APP_ID --repo upsquad-ai/upsquad-client --body "'"$APP_ID"'"'
sg upsquad-devs -c "GH_TOKEN=\$(python3 /opt/upsquad/upsquad-core/scripts/gh-token.py devops-engineer) \
gh secret set CORE_REPO_APP_PRIVATE_KEY --repo upsquad-ai/upsquad-client < $KEY_PATH"
# Mirror to upsquad-admin.
sg upsquad-devs -c 'GH_TOKEN=$(python3 /opt/upsquad/upsquad-core/scripts/gh-token.py devops-engineer) \
gh secret set CORE_REPO_APP_ID --repo upsquad-ai/upsquad-admin --body "'"$APP_ID"'"'
sg upsquad-devs -c "GH_TOKEN=\$(python3 /opt/upsquad/upsquad-core/scripts/gh-token.py devops-engineer) \
gh secret set CORE_REPO_APP_PRIVATE_KEY --repo upsquad-ai/upsquad-admin < $KEY_PATH"
Verify with:
sg upsquad-devs -c 'GH_TOKEN=$(python3 /opt/upsquad/upsquad-core/scripts/gh-token.py devops-engineer) \
gh api repos/upsquad-ai/upsquad-client/actions/secrets | jq .secrets[].name'
Expect both CORE_REPO_APP_ID and CORE_REPO_APP_PRIVATE_KEY to be
listed, on both consumer repos.
Rotation
Scheduled (every 90 days)
- Generate a new private key for
upsquad-devops-engineervia the GitHub App settings UI (requires an org owner session — the founder). - Write it to
/opt/upsquad-keys/devops-engineer.private-key.pem, mode0640, ownerupsquad-devs. - Re-run the provisioning block above to overwrite
CORE_REPO_APP_PRIVATE_KEYon both consumer repos. - Do not delete the old key from the App until the new key is
green on a PR in each consumer repo (the
Stub Drift Checkjob serves as the smoke test). - Delete the old key from the App settings UI.
Emergency (key leaked)
- Revoke the leaked key from the GitHub App settings UI immediately (this invalidates any in-flight installation tokens within 60 minutes).
- Generate a replacement key, write to
/opt/upsquad-keys/, re-run provisioning. - File a security incident in
docs/security/perincident-response-plan.md.
Workflow hardening requirements
The consumer-side stub-drift.yml workflow MUST:
- Fail hard on drift when the App secrets are present. No soft warnings.
- Fail hard on misconfig when
CORE_REPO_APP_IDorCORE_REPO_APP_PRIVATE_KEYis missing — absence of the secret is a repository configuration error, not a reason to wave PRs through. - Run on
pushtomainand onpull_requestso drift is caught before merge, not after. - Use
actions/create-github-app-token@v1(pin exact SHA, not tag) to mint the installation token. Never log the token.
A reference template is checked in at
docs/runbooks/templates/stub-drift.yml; consumer repos should match
that shape.
Why not a PAT?
- PATs live forever until rotated; App installation tokens expire in one hour. Smaller blast radius on leak.
- PATs are scoped to a user, not to a principal — if the person who minted it leaves the org or has their session compromised, every PAT they own is affected. The App identity is stable.
- App tokens are auditable as
upsquad-devops-engineer[bot]in the core repo's audit log; PAT calls show up as the human user.
References
- core#851 — this provisioning task.
- client#111 — the stub-drift incident that surfaced the degraded workflow.
scripts/gh-token.py— local equivalent of the workflow's token minting.docs/runbooks/migration-to-upsquad-ai.md— org migration context that created theupsquad-devops-engineerApp.