Skip to content
Open
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
114 changes: 114 additions & 0 deletions docs/release-github-app.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions scripts/set-release-github-app-secrets.sh
Original file line number Diff line number Diff line change
@@ -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
Loading