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.