Skip to main content

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:

SecretValueRotates
CORE_REPO_APP_IDNumeric App ID of upsquad-devops-engineerOn App rotation only (rare)
CORE_REPO_APP_PRIVATE_KEYPEM-encoded private key for the AppEvery 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)​

  1. Generate a new private key for upsquad-devops-engineer via the GitHub App settings UI (requires an org owner session — the founder).
  2. Write it to /opt/upsquad-keys/devops-engineer.private-key.pem, mode 0640, owner upsquad-devs.
  3. Re-run the provisioning block above to overwrite CORE_REPO_APP_PRIVATE_KEY on both consumer repos.
  4. 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 Check job serves as the smoke test).
  5. Delete the old key from the App settings UI.

Emergency (key leaked)​

  1. Revoke the leaked key from the GitHub App settings UI immediately (this invalidates any in-flight installation tokens within 60 minutes).
  2. Generate a replacement key, write to /opt/upsquad-keys/, re-run provisioning.
  3. File a security incident in docs/security/ per incident-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_ID or CORE_REPO_APP_PRIVATE_KEY is missing — absence of the secret is a repository configuration error, not a reason to wave PRs through.
  • Run on push to main and on pull_request so 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 the upsquad-devops-engineer App.