diff --git a/docs/release-github-app.md b/docs/release-github-app.md new file mode 100644 index 0000000..d8f88ca --- /dev/null +++ b/docs/release-github-app.md @@ -0,0 +1,114 @@ +# GitHub App for Release Automation + +This guide sets up the GitHub App that release workflows (such as `release-please`) use to open release pull requests, push version bumps, and create tags and GitHub releases. + +Workflows never store a long-lived release token. They store the App's Client ID and private key as organization secrets, and mint a short-lived token at run time with the `github.release-please` action. + +## Why a GitHub App + +| Option | Problem | +|---|---| +| `GITHUB_TOKEN` | Events created with it do not trigger other workflows. CI does not run on the release pull request, and tags or releases it creates do not trigger `push: tags` or `release` workflows. | +| Personal access token | Tied to a person, long-lived, and usually broader than needed. Releases stop when that person leaves or the token expires. | +| **GitHub App** | Owned by the organization, limited to the permissions below, installed only on selected repositories, and its installation tokens expire after one hour. | + +## 1. Create the App + +Go to **Organization settings → Developer settings → GitHub Apps → New GitHub App** and fill in: + +| Field | Value | +|---|---| +| GitHub App name | `tedilabs-release` (any unique name) | +| Homepage URL | `https://github.com/tedilabs` | +| Webhook → Active | **Unchecked**. Release workflows call the API; the App does not need events. | +| Where can this GitHub App be installed? | **Only on this account** | + +### Permissions + +Grant only these **repository permissions**. Leave every organization and account permission at **No access**, and subscribe to no events. + +| Repository permission | Access | Used for | +|---|---|---| +| Contents | Read and write | Push the release branch (the `VERSION` bump), create tags and GitHub releases | +| Pull requests | Read and write | Open and update the release pull request | +| Issues | Read and write | Create and apply the `autorelease: pending` / `autorelease: tagged` labels (labels use the Issues API) | +| Metadata | Read-only | Required for every App; selected automatically | + +`release-please` documents the same three write permissions (`contents`, `issues`, `pull-requests`) for its workflow token. + +Do not grant **Workflows**. A release pull request only changes `VERSION` (and a changelog, if enabled). Add it only if a release ever needs to modify files under `.github/workflows/`. + +## 2. Generate credentials + +On the App's settings page: + +1. Copy the **Client ID** (it starts with `Iv`), not the numeric App ID. Workflows pass it to the `github_app_client_id` input of `github.release-please`, which passes it as the `client-id` input of `actions/create-github-app-token` v3. `client-id` replaces the deprecated `app-id`. +2. Under **Private keys**, select **Generate a private key**. A `.pem` file is downloaded. Store it as a secret in step 4, then delete the local file. + +## 3. Install the App + +On the App's settings page, select **Install App → tedilabs → Only select repositories** and choose the repositories that release with it. Start with the pilot repository (`terraform-aws-account`) and add more as the rollout proceeds. + +## 4. Store the credentials as organization secrets + +Use **Selected repositories** visibility and keep the list in sync with the App installation. + +| Secret | Value | +|---|---| +| `RELEASE_GITHUB_APP_CLIENT_ID` | The Client ID from step 2 | +| `RELEASE_GITHUB_APP_PRIVATE_KEY` | The full contents of the `.pem` file from step 2 | + +With the GitHub CLI (requires an organization admin): + +```sh +gh secret set RELEASE_GITHUB_APP_CLIENT_ID --org tedilabs --visibility selected --repos terraform-aws-account +gh secret set RELEASE_GITHUB_APP_PRIVATE_KEY --org tedilabs --visibility selected --repos terraform-aws-account < tedilabs-release.private-key.pem +``` + +To add a repository later, add it to the App installation **and** to the visibility of both secrets. + +### On the GitHub Free plan + +Private repositories cannot use organization secrets on the GitHub Free plan. Set the same two secrets as repository secrets in every Terraform module repository instead: + +```sh +export RELEASE_GITHUB_APP_CLIENT_ID=Iv23li0123456789abcd +export RELEASE_GITHUB_APP_PRIVATE_KEY="$(cat tedilabs-release.private-key.pem)" +scripts/set-release-github-app-secrets.sh --dry-run # list the target repositories +scripts/set-release-github-app-secrets.sh # or pass repository names to limit the targets +unset RELEASE_GITHUB_APP_CLIENT_ID RELEASE_GITHUB_APP_PRIVATE_KEY +``` + +The script targets the non-archived `tedilabs` repositories named `terraform-*` with the `type-module` topic, passes the values to `gh` through standard input, deletes the `RELEASE_GITHUB_APP_ID` secret that an earlier version of this guide used, and lists the repositories where it failed. Run it again after rotating the key or adding a repository. Workflows read repository secrets with the same `secrets.RELEASE_GITHUB_APP_CLIENT_ID` and `secrets.RELEASE_GITHUB_APP_PRIVATE_KEY` expressions. + +## 5. Use it in a workflow + +```yaml +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + + steps: + - name: Release Please + uses: tedilabs/github-actions/.github/actions/github.release-please@main + with: + github_app_client_id: ${{ secrets.RELEASE_GITHUB_APP_CLIENT_ID }} + github_app_private_key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} +``` + +The action mints a token narrowed to `contents`, `issues`, and `pull-requests` write, even if the App has more permissions, and runs `release-please` with `release-please-config.json` and `.release-please-manifest.json`. The token is scoped to the current repository and is revoked when the job ends. The workflow-level `permissions: {}` drops the unused `GITHUB_TOKEN` permissions. + +## What changes for maintainers + +- Release pull requests and releases are authored by `tedilabs-release[bot]` (the App name followed by `[bot]`). +- CI runs on release pull requests, and tags or releases created by the App trigger `push: tags` and `release` workflows. +- When `main` is protected by a ruleset later, add the App as a bypass actor only if the release flow needs to push to `main` directly. `release-please` itself goes through a pull request. + +## Rotating or revoking + +- **Rotate the key**: generate a new private key, update `RELEASE_GITHUB_APP_PRIVATE_KEY`, then delete the old key on the App's settings page. +- **Stop releases from a repository**: remove it from the App installation and from the secret visibility. +- **Suspected leak**: delete the private key on the App's settings page. Every token minted from it stops working within an hour, and new tokens cannot be minted. diff --git a/scripts/set-release-github-app-secrets.sh b/scripts/set-release-github-app-secrets.sh new file mode 100755 index 0000000..8209893 --- /dev/null +++ b/scripts/set-release-github-app-secrets.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Set the release GitHub App credentials as repository secrets in every Terraform module repository. +# +# On the GitHub Free plan, private repositories cannot use organization secrets, so this sets repository +# secrets instead. The values are read from environment variables and passed to `gh` through standard input, +# so they never appear in command-line arguments. Once both are set, the `RELEASE_GITHUB_APP_ID` secret that an +# earlier version of this script set is deleted from the repository. +# +# Usage: +# export RELEASE_GITHUB_APP_CLIENT_ID=Iv23li0123456789abcd +# export RELEASE_GITHUB_APP_PRIVATE_KEY="$(cat tedilabs-release.private-key.pem)" +# scripts/set-release-github-app-secrets.sh [--dry-run] [REPOSITORY...] +# +# Without REPOSITORY arguments, it targets every non-archived repository of the owner (`GITHUB_OWNER`, defaults to +# `tedilabs`) whose name starts with `terraform-` and that has the `type-module` topic. +# Requires `gh` authenticated as a user who can manage secrets of those repositories. +set -euo pipefail + +owner="${GITHUB_OWNER:-tedilabs}" +legacy_secret=RELEASE_GITHUB_APP_ID +dry_run=false +repos=() +for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=true ;; + -h | --help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "[x] Unknown option: $arg" >&2; exit 1 ;; + *) repos+=("$arg") ;; + esac +done + +: "${RELEASE_GITHUB_APP_CLIENT_ID:?Set RELEASE_GITHUB_APP_CLIENT_ID to the Client ID of the App.}" +: "${RELEASE_GITHUB_APP_PRIVATE_KEY:?Set RELEASE_GITHUB_APP_PRIVATE_KEY to the contents of the private key (.pem).}" +if [[ ! $RELEASE_GITHUB_APP_CLIENT_ID =~ ^Iv[0-9A-Za-z.]+$ ]]; then + echo "[x] RELEASE_GITHUB_APP_CLIENT_ID must be the Client ID (it starts with 'Iv'), not the numeric App ID." >&2 + exit 1 +fi +if [[ $RELEASE_GITHUB_APP_PRIVATE_KEY != "-----BEGIN "*"PRIVATE KEY-----"* ]]; then + echo "[x] RELEASE_GITHUB_APP_PRIVATE_KEY must be the contents of the .pem file, not its path." >&2 + exit 1 +fi + +if [ ${#repos[@]} -eq 0 ]; then + while IFS= read -r repo; do + repos+=("$repo") + done < <(gh repo list "$owner" --topic type-module --no-archived --limit 1000 --json name \ + --jq '.[].name | select(startswith("terraform-"))' | sort) +fi +if [ ${#repos[@]} -eq 0 ]; then + echo "[x] No repositories found in '${owner}'." >&2 + exit 1 +fi +echo "[!] Setting RELEASE_GITHUB_APP_CLIENT_ID and RELEASE_GITHUB_APP_PRIVATE_KEY in ${#repos[@]} repositories of '${owner}'." + +failed=() +for repo in "${repos[@]}"; do + # Only an existing legacy secret is deleted, so a failed delete is a real failure. + if ! names=$(gh secret list --repo "${owner}/${repo}" --json name --jq '.[].name'); then + echo "[x] failed to list secrets: ${owner}/${repo}" >&2 + failed+=("$repo") + continue + fi + delete_legacy=false + if grep -qx "$legacy_secret" <<< "$names"; then + delete_legacy=true + fi + + if $dry_run; then + if $delete_legacy; then + echo " would set: ${owner}/${repo}, would delete: ${legacy_secret}" + else + echo " would set: ${owner}/${repo}" + fi + continue + fi + if printf '%s' "$RELEASE_GITHUB_APP_CLIENT_ID" | gh secret set RELEASE_GITHUB_APP_CLIENT_ID --repo "${owner}/${repo}" > /dev/null \ + && printf '%s' "$RELEASE_GITHUB_APP_PRIVATE_KEY" | gh secret set RELEASE_GITHUB_APP_PRIVATE_KEY --repo "${owner}/${repo}" > /dev/null \ + && { ! $delete_legacy || gh secret delete "$legacy_secret" --repo "${owner}/${repo}" > /dev/null; }; then + if $delete_legacy; then + echo " set: ${owner}/${repo}, deleted: ${legacy_secret}" + else + echo " set: ${owner}/${repo}" + fi + else + echo "[x] failed: ${owner}/${repo}" >&2 + failed+=("$repo") + fi +done + +if [ ${#failed[@]} -gt 0 ]; then + echo "[x] Failed in ${#failed[@]} repositories: ${failed[*]}" >&2 + exit 1 +fi +if ! $dry_run; then + echo "[!] Done. Make sure the GitHub App is installed on the same repositories." +fi