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
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,34 @@ jobs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
# Mint a short-lived installation token from the dedicated GitHub App.
# Why: workflow runs created via the default GITHUB_TOKEN are
# deliberately blocked by GitHub from triggering downstream workflow
# runs (anti-recursion). That meant release-please's release PRs
# never had `pull_request` checks run against them, blocking merges
# behind required status checks. Using a GitHub App's installation
# token sidesteps the recursion-guard: PRs opened by the App
# trigger workflows normally.
#
# The token is scoped to this repository, expires in ~1 hour, and
# is fresh on every workflow run (no long-lived secret stored). The
# App's private key is the only credential at rest; rotating it is
# a one-line `gh secret set` away.
#
# See layer/MAINTAINER.md (or docs/CI.md) for App setup steps.
- name: Mint App installation token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.RELEASE_PLEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}

- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ steps.app-token.outputs.token }}

# Build the wheel + sdist exactly once and share via a workflow artifact.
# Three downstream jobs consume it (publish-artifacts, publish-pypi,
Expand Down
55 changes: 47 additions & 8 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,13 +488,49 @@ so you don't self-block your own PRs), `allow_force_pushes: false`,
> and linear-history. Force-pushes and deletions are blocked for
> admins too (those settings are not under `enforce_admins` control).
> Admin override emits a `Bypassed rule violations:` warning in the
> push response but proceeds. The trade-off is intentional:
> `enforce_admins: true` would also block release-please's bot PRs
> from being merged (bot PRs don't trigger CI under `GITHUB_TOKEN`,
> so required checks would always be "missing"), forcing either a
> protection-toggle dance per release or a Personal Access Token
> setup for `release-please-action`. Documented as a known
> trade-off; revisit via a separate change if needed.
> push response but proceeds. We keep `enforce_admins: false` so a
> single human can hot-fix a wedged release pipeline; the day-to-day
> safety net is the required status checks themselves.

### How release-please PRs trigger required checks

GitHub deliberately blocks the default `GITHUB_TOKEN` from creating
workflow runs (anti-recursion). PRs opened by `release-please-action`
under `GITHUB_TOKEN` therefore never had `pull_request` workflows fire
against them, leaving every required status check stuck at
"Expected — Waiting for status to be reported" — unmergeable without
manual unblocks.

`release.yml` solves this by minting a short-lived installation token
from a dedicated GitHub App before invoking `release-please-action`:

```yaml
- uses: actions/create-github-app-token@<sha> # v3.x
id: app-token
with:
app-id: ${{ vars.RELEASE_PLEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}

- uses: googleapis/release-please-action@<sha> # v4
with:
token: ${{ steps.app-token.outputs.token }}
```

PRs opened with the App's token are not subject to the recursion guard;
required checks (`CI passed`, `analyze (python)`, `review dependencies`,
`ensure SHA-pinned actions`) fire automatically.

The App `igorlg-release-bot` is registered to `igorlg`, installed on
`igorlg/cfn-handler` only, and granted exactly two repository
permissions: `Contents: write` (push the release branch + create tags)
and `Pull requests: write` (open the release PR). Two pieces of state:
`vars.RELEASE_PLEASE_APP_ID` (the App's numeric ID, non-sensitive) and
`secrets.RELEASE_PLEASE_PRIVATE_KEY` (the PEM private key, sensitive).
Tokens minted from this App are scoped to the install and expire after
~1 hour; nothing long-lived sits in the workflow run context.

A PAT is the alternative, but it would expire annually and require
manual rotation. The App's private key has no GitHub-imposed expiry.

### Why `examples-lint` is not required

Expand Down Expand Up @@ -627,9 +663,12 @@ divergence, including the local project's own version.

This was always going to break on the first release-please merge. CI on
PRs ran on the bot's release-please branch, where the same drift
existed, but those CI runs are short-circuited by GitHub's
existed, but those CI runs were short-circuited at the time by GitHub's
`secrets.GITHUB_TOKEN` not triggering follow-up PR-triggered workflows
on bot-authored branches — so the failure didn't surface pre-merge.
(The latter has since been resolved by switching `release.yml` to mint
its release-please token via a dedicated GitHub App; see
[How release-please PRs trigger required checks](#how-release-please-prs-trigger-required-checks).)

### What we changed

Expand Down
102 changes: 102 additions & 0 deletions openspec/changes/ci-release-please-app-auth/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Proposal: Authenticate release-please via a GitHub App

## Why

`release.yml`'s `release-please` job currently uses the default
`GITHUB_TOKEN` to push the release branch and open the release PR.
GitHub deliberately blocks the default `GITHUB_TOKEN` from creating
follow-up workflow runs (anti-recursion). The consequence: PRs opened
by `release-please-action` never have `pull_request` workflows fire
against them, so every required status check on `main`'s branch
protection (`CI passed`, `analyze (python)`, `review dependencies`,
`ensure SHA-pinned actions`) sits at "Expected — Waiting for status to
be reported", unmergeable without a manual unblock.

This first surfaced when we shipped v1.2.0 (the Lambda Layer feature
release): the maintainer had to push an empty commit to the
release-please branch from a real account before any check could run.
That works once but it's not a sustainable release process.

Two repair paths exist:

1. **Personal Access Token (PAT)** — fine-grained PAT with
`Contents: write` + `Pull requests: write`, stored as a repo
secret, passed to `release-please-action` via its `token` input.
PRs opened with a PAT trigger workflows normally. Cost: max 1-year
expiry, manual rotation each year.
2. **GitHub App** — a dedicated App registered to the maintainer,
installed on `igorlg/cfn-handler` only, granted exactly
`Contents: write` + `Pull requests: write`. The workflow mints a
short-lived (~1h) installation token on every run via
`actions/create-github-app-token@<sha>`; tokens trigger workflows
normally. Cost: ~10 extra minutes upfront, two repo state items
instead of one (App ID + private key); zero ongoing rotation.

The App approach wins after year 1 in the steady state and aligns with
the pattern used by AWS Powertools, AWS CDK, and other serious OSS
Python libraries. It also keeps the long-lived credential (the App's
private key) out of any individual workflow-run context — only
short-lived installation tokens appear in run logs.

## What Changes

### Workflow

- **MODIFIED** `.github/workflows/release.yml` — the `release-please`
job gains a preceding step that mints an installation token via
`actions/create-github-app-token@<sha>`. `release-please-action`
consumes the minted token through its `token` input instead of the
implicit `GITHUB_TOKEN`. No other jobs change.

### Repository state

- **NEW** `vars.RELEASE_PLEASE_APP_ID` — the numeric App ID,
non-sensitive, stored as a repository **variable** (not secret).
- **NEW** `secrets.RELEASE_PLEASE_PRIVATE_KEY` — the PEM private key,
the only long-lived credential at rest.
- **NEW** GitHub App `igorlg-release-bot` (or similar) — owned by
`igorlg`, installed only on `igorlg/cfn-handler`, granted exactly
`Contents: Read and write` and `Pull requests: Read and write`.

### Documentation

- **MODIFIED** `docs/CI.md` — replace the existing "known trade-off"
note about `GITHUB_TOKEN` not triggering release-please-PR checks
with a new "How release-please PRs trigger required checks" section
that documents the App registration, the workflow snippet, and the
rationale for choosing App over PAT.

### Out of scope

- **No** API or library code changes. This is purely a CI/release-
pipeline auth migration.
- **No** change to PyPI Trusted Publishing, OIDC into AWS, or any
other authentication path. Those remain as specified in the existing
`ci-infrastructure` baseline.
- **No** change to branch protection (`enforce_admins`, required
checks, linear-history) — same posture; the App fix means the
required checks now actually fire on release-please PRs without
manual unblocks.

## Capabilities

### Modified Capabilities

- `ci-infrastructure` — adds a new sub-requirement under the existing
"Release pipeline driven by Conventional Commits and Trusted
Publishing" requirement specifying the auth mechanism for
`release-please-action`.

## Impact

- **Maintainer workload**: ~15 minutes one-time UI setup; zero
recurring rotation.
- **Release pipeline**: every release-please PR now arrives with all
required checks running, no manual empty-commit unblocks.
- **Security posture**: the App's private key is the only long-lived
credential; minted tokens are short-lived and scoped to the install.
Compared to a PAT, no annual expiry / rotation chore.
- **User-facing**: none. Library API and Layer publishing unchanged.
- **Backward compatibility**: nothing to break — this is the first
release-please run since v1.2.0; the App auth replaces the default
`GITHUB_TOKEN` for all future runs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Spec delta: ci-infrastructure

## MODIFIED Requirements

### Requirement: Release pipeline driven by Conventional Commits and Trusted Publishing

Releases SHALL be driven entirely by Conventional Commits parsed by `release-please-action`. Merging the auto-generated release PR with the title `chore(main): release X.Y.Z` SHALL trigger a chain of jobs in `release.yml` that: tag `vX.Y.Z`; build wheel and sdist; upload artifacts to a GitHub Release; and publish to PyPI via OIDC Trusted Publishing in the `pypi` environment. The Trusted Publisher binding SHALL be parameterised by repository, workflow filename (`release.yml`), and environment name (`pypi`); no PyPI API token is held anywhere.

`release-please-action` SHALL authenticate using a short-lived installation token minted from a dedicated GitHub App (registered to the repository owner, installed only on this repository, granted exactly `Contents: write` and `Pull requests: write` permissions), NOT the default `GITHUB_TOKEN`. The minting step SHALL run before `release-please-action` and pass the resulting token via the action's `token` input. This requirement exists because GitHub blocks PRs opened with the default `GITHUB_TOKEN` from triggering downstream workflow runs (anti-recursion); without an App-minted token, required status checks on the release PR never fire and the PR cannot be merged. The App's numeric ID SHALL be stored as a repository **variable** (`vars.RELEASE_PLEASE_APP_ID`, non-sensitive); its private key SHALL be stored as a repository **secret** (`secrets.RELEASE_PLEASE_PRIVATE_KEY`).

#### Scenario: A `feat:` commit lands on main
- **WHEN** a contributor merges a PR with title `feat: <description>` to `main`
- **THEN** `release-please-action` opens (or updates) a release PR proposing a minor version bump

#### Scenario: The release PR is merged
- **WHEN** the release PR is squash-merged
- **THEN** `release.yml` runs `release-please-action`, sees `release_created=true`, builds artifacts, uploads to GitHub Release, and the `publish-pypi` job authenticates via OIDC and uploads the artifacts to PyPI

#### Scenario: PyPI Trusted Publisher is misconfigured
- **WHEN** the publisher binding does not match (wrong workflow filename, wrong environment, wrong repo)
- **THEN** `pypa/gh-action-pypi-publish` fails the OIDC exchange and the publish step errors with a 403 from PyPI; the wheel/sdist artifacts on the GitHub Release are unaffected

#### Scenario: Release PR opened with the App's token triggers required checks
- **WHEN** `release-please-action` opens or updates a release PR using the GitHub App installation token
- **THEN** the four required status checks on `main`'s branch protection (`CI passed`, `analyze (python)`, `review dependencies`, `ensure SHA-pinned actions`) all run automatically against the release PR's head, with no manual unblocks needed

#### Scenario: App credential is missing or invalid
- **WHEN** `vars.RELEASE_PLEASE_APP_ID` is unset, or `secrets.RELEASE_PLEASE_PRIVATE_KEY` is missing or expired
- **THEN** the `Mint App installation token` step fails before `release-please-action` runs; the release pipeline halts loudly rather than silently falling back to `GITHUB_TOKEN` (which would produce non-triggering PRs)
55 changes: 55 additions & 0 deletions openspec/changes/ci-release-please-app-auth/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Tasks: Authenticate release-please via a GitHub App

## 1. Maintainer one-time UI setup (manual; gates the merge)

- [x] 1.1 Igor: register a new GitHub App at https://github.com/settings/apps/new owned by the `igorlg` user account. Name: `igorlg-release-bot` (or globally-unique equivalent). Webhook: **disabled**. Repository permissions: `Contents: Read and write`, `Pull requests: Read and write`, `Metadata: Read-only` (auto). Where can this App be installed: only on this account.
- [x] 1.2 Igor: generate a private key from the App's settings page; download the `.pem`. Note the App's numeric ID from the App settings page header.
- [x] 1.3 Igor: install the App (left sidebar of App settings → Install App → next to `igorlg` → Install) on the `igorlg/cfn-handler` repository only.
- [x] 1.4 Igor: store the App ID as a repository variable: `gh variable set RELEASE_PLEASE_APP_ID --repo igorlg/cfn-handler --body '<numeric-id>'`. Verify with `gh variable list --repo igorlg/cfn-handler`.
- [x] 1.5 Igor: store the private key as a repository secret: `gh secret set RELEASE_PLEASE_PRIVATE_KEY --repo igorlg/cfn-handler --body "$(cat <path-to-pem>)"`. Verify with `gh secret list --repo igorlg/cfn-handler`.
- [x] 1.6 Igor: delete the previously-created `RELEASE_PLEASE_TOKEN` PAT secret (no longer used): `gh secret delete RELEASE_PLEASE_TOKEN --repo igorlg/cfn-handler`. Optional: revoke the PAT itself in GitHub settings → Developer settings → Fine-grained tokens.

## 2. Workflow YAML changes

- [x] 2.1 Edit `.github/workflows/release.yml`: add a new step in the `release-please` job, before the existing `googleapis/release-please-action` step. The new step has `id: app-token`, uses `actions/create-github-app-token@<sha>` (latest pinned SHA, with the `# vX.Y.Z` comment per `secure-workflows.yml`'s policy), and passes `app-id: ${{ vars.RELEASE_PLEASE_APP_ID }}` and `private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}`.
- [x] 2.2 In the same job: pass the minted token to `release-please-action` via its `token` input: `token: ${{ steps.app-token.outputs.token }}`. No other inputs change.
- [x] 2.3 Pin `actions/create-github-app-token` to a commit SHA with a `# vX.Y.Z` comment so `secure-workflows.yml` accepts the change.

## 3. Documentation updates

- [x] 3.1 Edit `docs/CI.md`: replace the "Note on admin bypass" `enforce_admins: true` trade-off paragraph that referred to `GITHUB_TOKEN` not triggering release-please-PR checks. The new wording explains that the GitHub App fix makes that trade-off obsolete.
- [x] 3.2 Edit `docs/CI.md`: add a new "How release-please PRs trigger required checks" section under the branch-protection discussion. Show the workflow snippet (the new `actions/create-github-app-token` step) and explain why an App is preferred over a PAT (no annual rotation; short-lived per-run tokens; only the private key is at rest).
- [x] 3.3 Edit `docs/CI.md` postmortem section ("Root cause #2"): update the parenthetical about `GITHUB_TOKEN` to note that the limitation is now resolved by the App-token fix, with a back-reference to the new section.

## 4. Local verification (before push)

- [x] 4.1 `just ci-check` — pure tests, no library code change; sanity check. (103 tests pass; coverage 99.48%.)
- [x] 4.2 `just openspec-validate` — confirm the change validates strictly against the existing `ci-infrastructure` baseline spec.
- [x] 4.3 Visually inspect the release.yml diff: only the `release-please` job changes; permissions block unchanged; output declarations unchanged; downstream jobs' dependencies unchanged.

## 5. PR open

- [x] 5.1 Stage all changes; commit with title `ci(release): authenticate release-please via a GitHub App`. The `ci:` prefix is correct — this is a release-pipeline change with no version-bump implications.
- [x] 5.2 Branch `ci/release-please-app-token` (already created); push.
- [x] 5.3 `gh pr create` against `main`. PR description: link to `openspec/changes/ci-release-please-app-auth/proposal.md`. Highlight that section 1 of `tasks.md` is the maintainer UI work that gates the merge (already done before the PR opens, by design).

## 6. Cloud CI on the PR

- [x] 6.1 Watch `secure-workflows.yml` re-validate the new SHA-pinned action and report SUCCESS.
- [x] 6.2 Watch `ci.yml` matrix + lint pass (no library changes; should be green).
- [x] 6.3 Watch `analyze (python)` and `review dependencies` complete.

## 7. Merge + first post-merge release

- [ ] 7.1 Squash-merge the PR. Title format: `ci(release): authenticate release-please via a GitHub App`. The `ci:` prefix produces no version bump.
- [ ] 7.2 The merge does NOT itself trigger a release (no `feat:` / `fix:` since the v1.2.0 ship). The next `feat:` / `fix:` merge will be the first release using the App. Watch that release-please run end-to-end:
- `Mint App installation token` step executes successfully
- `release-please bot` opens a release PR (PR title `chore(main): release X.Y.Z`)
- **All required checks fire automatically on the release PR** (no manual empty-commit unblock)
- Squash-merging the release PR triggers the full downstream pipeline
- [ ] 7.3 Confirm via the run logs that `steps.app-token.outputs.token` is consumed by `release-please-action` and that no `GITHUB_TOKEN`-based fallback occurred.

## 8. Validate + archive

- [x] 8.1 `openspec validate ci-release-please-app-auth --strict` passes before merging the PR.
- [ ] 8.2 After PR merge + first release-please PR appears with checks running: `openspec archive ci-release-please-app-auth`. The MODIFIED requirement in this delta merges back into the `ci-infrastructure` baseline spec.