Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/actions/decrypt-secret/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Decrypt secret
description: >-
Decrypt a value produced by encrypt-secret, masking the plaintext before
anything else can read it. Run this before any step that executes code you
do not control.

inputs:
encrypted_value:
description: Base64 ciphertext from encrypt-secret.
required: true
encryption_key:
description: Symmetric passphrase shared with the encrypting job.
required: true

outputs:
value:
description: Decrypted plaintext, masked in logs.
value: ${{ steps.decrypt.outputs.value }}

runs:
using: composite
steps:
- name: Decrypt
id: decrypt
shell: bash
env:
ENCRYPTED_VALUE: ${{ inputs.encrypted_value }}
ENC_KEY: ${{ inputs.encryption_key }}
run: |
: "${ENCRYPTED_VALUE:?}" "${ENC_KEY:?}"
value=$(printf '%s' "${ENCRYPTED_VALUE}" | openssl enc -d -aes-256-cbc -pbkdf2 -pass env:ENC_KEY -base64 -A)
# Masking is per line, so a multi-line value needs each line masked.
while IFS= read -r line; do
if [ -n "${line}" ]; then
echo "::add-mask::${line}"
fi
done <<<"${value}"
delimiter=$(openssl rand -hex 16)
{
echo "value<<${delimiter}"
echo "${value}"
echo "${delimiter}"
} >>"${GITHUB_OUTPUT}"
32 changes: 32 additions & 0 deletions .github/actions/encrypt-secret/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Encrypt secret
description: >-
Encrypt a value so it can cross a job boundary. GitHub silently drops masked
plaintext from job outputs; ciphertext survives the trip and is useless
without the key.

inputs:
value:
description: Plaintext to encrypt. Must never be echoed.
required: true
encryption_key:
description: Symmetric passphrase shared with the decrypting job.
required: true

outputs:
encrypted_value:
description: Base64 ciphertext, safe to expose as a job output.
value: ${{ steps.encrypt.outputs.encrypted_value }}

runs:
using: composite
steps:
- name: Encrypt
id: encrypt
shell: bash
env:
VALUE: ${{ inputs.value }}
ENC_KEY: ${{ inputs.encryption_key }}
run: |
: "${VALUE:?}" "${ENC_KEY:?}"
encrypted=$(printf '%s' "${VALUE}" | openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:ENC_KEY -base64 -A)
echo "encrypted_value=${encrypted}" >>"${GITHUB_OUTPUT}"
61 changes: 61 additions & 0 deletions .github/actions/github-app-token/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: GitHub App token
description: Mint an installation token, signing the App JWT in Vault so the private key never reaches the runner.

inputs:
vault-address:
description: Vault instance holding the signing key.
required: true
vault-role:
description: JWT role for the calling workflow, which is what binds this to one caller.
required: true
app-id:
description: GitHub App to authenticate as. `GET /apps/<slug>` returns it, so it is not a secret.
required: true
transit-key:
description: Transit key holding that App's private key, named `github-app-<slug>`.
required: true
cf-access-client-id:
description: Cloudflare Access service token id for that Vault instance.
required: true
cf-access-client-secret:
description: Cloudflare Access service token secret for that Vault instance.
required: true
repository:
description: Repository to scope the token to, as `owner/name`.
required: true

outputs:
token:
description: Installation token, scoped to `repository` and valid for an hour.
value: ${{ steps.token.outputs.token }}

# The three App inputs travel together: the key is the one GitHub has registered
# for that App, and the role's policy grants signing with that one key. A caller
# that changes one and not the others gets a 403 from Vault or a 401 from GitHub.
runs:
using: composite
steps:
- name: Authenticate Vault
id: vault
uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4
with:
url: ${{ inputs.vault-address }}
method: jwt
role: ${{ inputs.vault-role }}
outputToken: true
extraHeaders: |
CF-Access-Client-Id: ${{ inputs.cf-access-client-id }}
CF-Access-Client-Secret: ${{ inputs.cf-access-client-secret }}

- name: Mint installation token
id: token
shell: bash
env:
VAULT_ADDR: ${{ inputs.vault-address }}
VAULT_TOKEN: ${{ steps.vault.outputs.vault_token }}
CF_ACCESS_CLIENT_ID: ${{ inputs.cf-access-client-id }}
CF_ACCESS_CLIENT_SECRET: ${{ inputs.cf-access-client-secret }}
TRANSIT_KEY: ${{ inputs.transit-key }}
APP_ID: ${{ inputs.app-id }}
REPOSITORY: ${{ inputs.repository }}
run: "${GITHUB_ACTION_PATH}/token.sh"
52 changes: 52 additions & 0 deletions .github/actions/github-app-token/token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Exchange a Vault-signed App JWT for an installation token.
set -euo pipefail

: "${VAULT_ADDR:?}" "${VAULT_TOKEN:?}" "${TRANSIT_KEY:?}" "${APP_ID:?}" "${REPOSITORY:?}"
: "${CF_ACCESS_CLIENT_ID:?}" "${CF_ACCESS_CLIENT_SECRET:?}"

base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}

# GitHub rejects App JWTs living longer than ten minutes. The backdated `iat`
# absorbs clock skew between the runner and GitHub.
now=$(date +%s)
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}"

# transit hashes the input itself, so `prehashed` stays false.
request=$(jq --null-input \
--arg input "$(printf '%s' "${signing_input}" | openssl base64 -A)" \
'{input: $input, signature_algorithm: "pkcs1v15", hash_algorithm: "sha2-256", prehashed: false}')

signature=$(curl --silent --show-error --fail-with-body --request POST \
--header "X-Vault-Token: ${VAULT_TOKEN}" \
--header "Content-Type: application/json" \
--header "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
--header "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
--data "${request}" \
"${VAULT_ADDR%/}/v1/transit/sign/${TRANSIT_KEY}" | jq --exit-status --raw-output '.data.signature')

# Vault prefixes the base64 signature with the key version that produced it.
jwt="${signing_input}.$(printf '%s' "${signature#vault:v*:}" | tr '+/' '-_' | tr -d '=')"

github_api() {
curl --silent --show-error --fail-with-body \
--header "Authorization: Bearer ${jwt}" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$@"
}

# Looked up rather than configured, so a new caller needs no extra input.
# `--exit-status` so a 200 that carries no field fails here rather than further
# down as an unexplained 401.
installation=$(github_api "https://github.com/ghapi/repos/${REPOSITORY}/installation" | jq --exit-status --raw-output '.id')
token=$(github_api --request POST \
--data "$(jq --null-input --arg repo "${REPOSITORY#*/}" '{repositories: [$repo]}')" \
"https://github.com/ghapi/app/installations/${installation}/access_tokens" | jq --exit-status --raw-output '.token')

echo "::add-mask::${token}"
echo "token=${token}" >>"${GITHUB_OUTPUT}"
30 changes: 30 additions & 0 deletions .github/actions/install-renovate/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Install Renovate
description: Put a centrally pinned Renovate on PATH, including its config validator.

# The version lives in this directory's package-lock.json, which Renovate keeps
# up to date itself. `$/` materialises this directory at the running commit, so
# a caller in another repository gets the same pin without a checkout.
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24

# `npm ci` resolves nothing at runtime, so a package published after the
# lockfile was written cannot enter the tree. Renovate treats `re2` as
# optional and falls back to native RegExp when the native build is missing.
- name: Install
shell: bash
working-directory: ${{ github.action_path }}
run: |
npm ci --ignore-scripts --no-audit --no-fund
echo "${PWD}/node_modules/.bin" >>"${GITHUB_PATH}"

# The image's Yarn 1 cannot write a Yarn 4 lockfile, so Renovate falls back
# to a `package.json`-only pull request. Corepack runs the version each
# repository's `packageManager` pins; shimming `yarn` alone spares `npm ci`.
- name: Enable Corepack
shell: bash
run: corepack enable yarn
Loading
Loading