SRE-924: Sign the Renovate App JWT in Vault - #95
Conversation
The App's private key was a Vault secret any `dev`-role job could read, which is how the 2026-08-04 compromise reached it. A composite action now has Vault sign the App JWT instead, so the key never lands on the runner. The same action carries the Renovate pin, and `$/` resolves both at the running commit. That replaces the OIDC dance that existed only to work out which ref to check the lockfile out from, and the checkout with it. Shell in this repo now needs a linter, so lint.yml grew a shellcheck job.
The role is the per-caller boundary, so it cannot be baked into an action that more than one workflow uses.
A second App means the caller has to name which one it signs as, and the key and role have to agree with it.
PR SummaryMedium Risk Overview Reusable workflow callers must supply Lint adds shellcheck for tracked Reviewed by Cursor Bugbot for commit c182b67. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Pull request overview
This PR updates the centralized Renovate reusable workflow to mint GitHub App installation tokens by having Vault Transit sign the App JWT (so the private key never reaches the runner), and refactors Renovate installation into a pinned composite action using the new $/ self-repo syntax.
Changes:
- Replace “read GitHub App private key from Vault secret” with a composite action that uses Vault Transit to sign the App JWT and exchanges it for a repo-scoped installation token.
- Introduce a composite
install-renovateaction that installs a centrally pinned Renovate version and puts it onPATH, removing per-workflownpm cisetup. - Add a
shellchecklint job for repository shell scripts.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| .github/workflows/lint.yml | Adds shell linting and switches Renovate validation to the shared install action. |
| .github/workflows/housekeeping-dependencies.yml | Removes OIDC ref/checkout workaround; uses the new Vault-signed GitHub App token action and shared Renovate installer. |
| .github/actions/install-renovate/action.yml | New composite action that sets up Node and installs Renovate from a centrally pinned lockfile. |
| .github/actions/install-renovate/package.json | Defines Renovate as the pinned dependency for the installer action. |
| .github/actions/install-renovate/package-lock.json | Locks the Renovate dependency tree used by the installer action. |
| .github/actions/github-app-token/action.yml | New composite action to authenticate to Vault and mint a repo-scoped GitHub App installation token. |
| .github/actions/github-app-token/token.sh | Implements JWT construction, Vault Transit signing, and GitHub installation token exchange. |
Suppressed comments (2)
.github/actions/github-app-token/action.yml:60
inputs.repositorymay be empty (and the input default cannot safely reference contexts). Use an expression fallback here so the action scopes to the current repo when callers don't providerepository.
TRANSIT_KEY: ${{ inputs.transit-key }}
APP_ID: ${{ inputs.app-id }}
REPOSITORY: ${{ inputs.repository }}
.github/actions/github-app-token/token.sh:46
- Similarly, fail fast if the installation lookup or access token response doesn't contain the expected fields; otherwise
jq -rcan producenulland the script will continue with invalid values.
# Looked up rather than configured, so a new caller needs no extra input.
installation=$(github_api "https://api.github.com/repos/${REPOSITORY}/installation" | jq --raw-output '.id')
token=$(github_api --request POST \
--data "$(jq --null-input --arg repo "${REPOSITORY#*/}" '{repositories: [$repo]}')" \
"https://api.github.com/app/installations/${installation}/access_tokens" | jq --raw-output '.token')
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6784697. Configure here.
A 200 without the expected field made `jq --raw-output` print `null` and exit zero, so a missing signature surfaced later as an unexplained 401. `repository` loses its expression default. Whether action input defaults evaluate expressions is undocumented, and a caller naming the repository costs one line.
Declaring it required would have broken every caller on merge: SRE-901 removed `secrets: inherit`, and a caller cannot pass a secret the called workflow does not declare yet, so there is no order in which required lands cleanly.
Reverts the previous commit's required: false. The workflow cannot reach Vault without it, so the declaration says so and the nine callers pass it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
.github/workflows/housekeeping-dependencies.yml:77
- This secret is declared optional, but the workflow always passes it into the github-app-token action, whose script hard-requires CF_ACCESS_CLIENT_SECRET. Callers that have not been updated to pass this secret will still be able to dispatch the workflow (because required: false) but will then fail at runtime. If this workflow cannot function without it, it should be marked required so callers fail at dispatch time as intended.
contents: read
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.github/actions/github-app-token/token.sh:17
- The App JWT payload encodes
issas a JSON string ("iss":"${APP_ID}"). GitHub App JWTs expectissto be the numeric App ID, and string-encoding can cause authentication failures when exchanging for an installation token.
header=$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url)
payload=$(printf '{"iss":"%s","iat":%d,"exp":%d}' "${APP_ID}" "$((now - 60))" "$((now + 540))" | base64url)
signing_input="${header}.${payload}"
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/actions/github-app-token/token.sh:28
- The Vault transit API request body is JSON, but the curl call doesn’t set a JSON Content-Type. Setting it explicitly avoids relying on server-side content sniffing and prevents regressions if Vault becomes stricter about request headers.
signature=$(curl --silent --show-error --fail-with-body --request POST \
--header "X-Vault-Token: ${VAULT_TOKEN}" \
--header "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
--header "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
--data "${request}" \
.github/actions/github-app-token/token.sh:48
- This GitHub API call sends a JSON body via
--data, but noContent-Type: application/jsonheader is set. Adding it makes the request unambiguous and avoids relying on GitHub accepting JSON with curl’s default form content type.
token=$(github_api --request POST \
--data "$(jq --null-input --arg repo "${REPOSITORY#*/}" '{repositories: [$repo]}')" \
"https://api.github.com/app/installations/${installation}/access_tokens" | jq --exit-status --raw-output '.token')
Vault parses the body either way, so this is not a fix; it matches what the Python client already sends and says what the body is.
Renovate shared an App with the release flow, so one key covered both. It now signs as hash-dependencies, and the title check exempts that account and the one changesets opens the release PR as.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/workflows/preflight-pr-title.yml:10
- The exemption list in the workflow
if:includeshash-dependencies[bot]andhash-release[bot], but this comment doesn’t name them, making it unclear which accounts are exempt (and why). Consider naming the exact logins to keep the documentation accurate and easy to audit.
# Exempt are `dependabot[bot]`, the account that opens renovate PRs, and the one
# changesets opens the release PR as. Any other account — including other bots
# and GitHub Apps such as Claude — must reference a Linear issue ID.
.github/actions/github-app-token/token.sh:49
- This POST to the GitHub REST API sends a JSON body, but no
Content-Type: application/jsonheader is set.curl --datadefaults toapplication/x-www-form-urlencoded, which can cause the API to reject the payload as invalid JSON.
token=$(github_api --request POST \
--data "$(jq --null-input --arg repo "${REPOSITORY#*/}" '{repositories: [$repo]}')" \
"https://api.github.com/app/installations/${installation}/access_tokens" | jq --exit-status --raw-output '.token')

Renovate authenticated as a GitHub App whose private key was a Vault secret the
job read into the runner. A composite action now has Vault's transit engine sign
the App JWT with a non-exportable key, and the installation token it exchanges
that for is scoped to the calling repository.
The
$/self-repository syntax went generally available on 2026-07-30, whichretires the TODO in this workflow: the OIDC step existed only to work out which
ref to check the Renovate lockfile out from. The lockfile now lives beside the
action that installs it, so the checkout goes away too, and
lint.ymlshares thesame pin instead of running its own
npm ci.Shell in this repo now needs a linter, hence the shellcheck job.
Note that actionlint 1.7.12 does not know
$/yet and reports "ref is missing".No workflow here runs it.