blob: 34068e0ef56b6d5f61403c4e950d673e9f4f1ef2 [file] [view] [edit]
# Release Automation and Unified Canary/Stable Branches
**Authors:** @lalitm
**Status:** Draft
## Problem
Perfetto's release process today is a patchwork of partially-automated scripts
and manual steps spread across three loosely-coupled pipelines — the SDK/native
binaries, the UI, and the Python package. Each has its own versioning model,
its own branching convention, and its own notion of "canary" vs "stable". The
result is a release workflow that is:
1. **Manual and error-prone.** `tools/release/release_perfetto.py` requires a
human to run it locally, answer prompts, wait on LUCI builds, hand-upload
artifacts, and manually create the GitHub release. A single release is a
multi-hour, multi-terminal babysitting exercise.
2. **Fragmented across artifacts.** The SDK uses `releases/vX.x` maintenance
branches and `vX.Y` tags. The UI uses `ui-canary` / `ui-stable` branches
plus a `channels.json` file in `main` that pins each channel to a specific
SHA. The Python package uses a hardcoded version string in `python/setup.py`
and a separate `release_python.py` script that is not run as part of the
normal release flow. These three things drift: the UI on `ui-stable` may
correspond to a completely different commit than the SDK tagged `vX.Y`.
3. **No clear "release-in-progress" state.** There is no single branch a
contributor can look at and say "this is what v54 will contain". The
decision of what goes into v54 is implicit in whoever ran the release
script and when.
4. **UI channels driven by a file, not a branch.** `channels.json` was a
pragmatic choice when it was introduced but it means:
- Cherry-picks to a channel require two PRs (one to the channel branch,
one to `main` to bump the SHA).
- The source of truth for "what is canary" is a JSON file, not the branch
named `ui-canary`.
- Anyone with commit access to `main` can redirect a channel.
5. **No PyPI automation.** Python releases happen rarely and out-of-band from
SDK releases. The version in `setup.py` is updated by hand, and pushing to
PyPI is a manual `twine upload`.
6. **LUCI build completion is not observable from GitHub.** Builds are
triggered by tag push (`refs/tags/upstream/v.+`) but there is no hook back
into the GitHub release. Whoever cut the release has to watch LUCI, wait
for all four platforms to finish, download the artifacts, and upload them
to the release by hand.
We want to get to a world where cutting a release is a small number of button
clicks in the GitHub UI, where the branch state at any moment tells you
exactly what is in flight, and where the SDK, UI, and Python package move
together.
## Decision
Pending.
## Design
### Summary
Replace the per-release `releases/vX.x` branches and the UI's `channels.json`
file with two long-lived branches — `canary` and `stable` — that are shared by
the SDK, the UI, and the Python package. All transitions between branches
(`main` → `canary`, `canary` → `stable`) are triggered by clicking
`workflow_dispatch` buttons in the GitHub Actions UI. The `stable` promotion
is what tags the release, and the tag push is the single event that fans out
to LUCI (native binaries), Cloud Build (UI), and GitHub Actions (PyPI + GitHub
Release creation). LUCI artifact attachment is reconciled by a final
human-triggered "finalize release" button.
Independent artifact producers (LUCI, Cloud Build, GH Actions) converge on
the same GitHub release using a "create draft if missing, else upload to
existing" idempotent pattern, so no producer needs to wait for any other.
### Branches
Three long-lived branches, each protected (no direct pushes; PRs only):
| Branch | Represents | Updated by |
|---|---|---|
| `main` | HEAD of development | normal PR merges |
| `canary` | Next release, feature-locked | button: `main` → `canary` (fast-forward) |
| `stable` | Current shipping release | button: `canary` → `stable` (fast-forward + tag) |
The existing `releases/vX.x` branches are frozen in place as historical
artifacts. We do not create new ones and we do not backport to them. This
matches current practice: we already only support the latest release.
The existing `ui-canary` and `ui-stable` branches go away, replaced by the
unified `canary` / `stable`. The `channels.json` file is deleted.
### Version numbering
The version is derived from the top of the `CHANGELOG` file, as today, via
`tools/write_version_header.py`. This now becomes the source of truth for
every artifact:
- Native binaries: `vX.Y` (C++ header generated at build time).
- UI: `vX.Y-<9-char-SHA>` (as today).
- Python package: `0.X.Y` (auto-derived from `CHANGELOG` at wheel-build time;
the hardcoded string in `python/setup.py` is removed). The `0.` prefix
keeps the package in the pre-1.0 series while encoding the Perfetto
release in the minor/patch components, giving a monotonic jump from the
current `0.16.0`.
The CHANGELOG is bumped in a normal PR to `main` before the `main` → `canary`
fast-forward. The person cutting canary is responsible for confirming that
`CHANGELOG` already contains the intended `vX.Y` entry.
### Release lifecycle
```
main
│ (1) click "Cut canary" ─── fast-forward canary to main SHA
canary ◄────── cherry-pick PRs target canary directly
│ (2) UI Cloud Build auto-deploys canary channel
│ on every push to `canary`
│ (3) click "Promote canary to stable"
stable ──► tag vX.Y pushed as part of the same action
│ (4) tag push triggers in parallel:
│ - LUCI official builds (4 platforms)
│ - Cloud Build UI deploy to stable channel
│ - GH Actions: build+publish PyPI wheel
│ - GH Actions: create draft GitHub release
│ (5) click "Finalize release" once LUCI is done
artifacts attached to draft
│ (6) human reviews notes and clicks "Publish" in the
│ GitHub UI
released
```
### The four buttons
All four are `workflow_dispatch` workflows under `.github/workflows/`.
**Button 1: `cut-canary.yml`**. Creates a merge commit on `canary` whose
tree equals the current tip of `main`, using the `git merge -s ours` + `git
read-tree -m -u` pattern (same approach as the existing
`merge-main-to-canary.yml`). This always moves forward (no force-push
needed) and handles the normal case where cherry-picks on `canary` have
different SHAs than their `main` counterparts, so a true "fast-forward"
would never succeed after one release cycle.
Convention: every fix lands on `main` first and is then cherry-picked to
`canary`. Anything that exists only on `canary` will be silently dropped at
the next cut, because we're replacing `canary`'s tree with `main`'s. The
dropped commit is still in canary's git history and can be re-cherry-picked
if needed. Opens no PR; the push itself is the event. Pushes to `canary`
trigger Cloud Build to redeploy the canary UI.
**Button 2: `promote-stable.yml`**. Applies the same tree-replacing merge
pattern to push `canary`'s tree onto `stable`, then creates and pushes the
`vX.Y` tag (version read from `CHANGELOG`) pointing at the resulting
`stable` HEAD. This single action is what triggers every downstream build.
**Button 3: `finalize-release.yml`**. Takes a version input (e.g. `v54.0`).
Downloads the LUCI artifacts from GCS, verifies they match the expected
manifest, and attaches them to the draft GitHub release. Leaves the
release as a draft — a maintainer reviews the release notes and clicks
"Publish" manually from the GitHub UI. Idempotent — safe to re-run.
Cherry-picks are opened as regular PRs targeting `canary`. There is no
"hotfix directly to stable" path: even emergency fixes go `main` → `canary`
→ `stable` via the normal buttons. This keeps the invariant that `stable` is
always an ancestor of `canary`, which in turn keeps the promotion button a
pure fast-forward.
### Artifact flow on tag push
The `vX.Y` tag push is the single event that fans out. Four independent
producers each converge on the same GitHub release using the
"create-if-missing, upload-on-exists" pattern:
```bash
if gh release view "$TAG" > /dev/null 2>&1; then
gh release upload "$TAG" <files> --clobber
else
gh release create "$TAG" --draft --generate-notes <files>
fi
```
Producers:
1. **LUCI native builds** (Linux, macOS, Windows, Android). Triggered as
today by the `refs/tags/upstream/v.+` scheduler rule. Each builder already
uploads to `gs://perfetto-luci-artifacts/<git-revision>/<arch>/<binary>`
(with a parallel `latest/` alias), and the SDK source zips land at
`gs://perfetto-luci-artifacts/<version>/sdk/`. Button 3 reconciles these
existing paths into the GitHub release — no new bucket or layout is
required.
2. **UI Cloud Build.** The existing trigger (formerly keyed on changes to
`channels.json`) is re-keyed to fire on pushes to the `stable` branch.
Builds the UI from `stable` HEAD and deploys to the stable channel.
3. **PyPI publish workflow** (`publish-pypi.yml`). Triggered by tag push.
Builds a pure-Python wheel with version derived from `CHANGELOG`, publishes
to PyPI using OIDC trusted publishing (no long-lived token). Does not ship
native binaries in v1.
4. **GitHub Release draft** (`draft-release.yml`). Triggered by tag push.
Creates the draft release; the body is pre-populated with the
corresponding `vX.Y` section of the `CHANGELOG` verbatim, so even if no
human touches it the release still has useful content. The draft exists
from the moment the tag is pushed; LUCI artifacts are attached
asynchronously.
Release notes themselves are human-authored, not automated. Before
clicking `finalize-release` a maintainer is expected to edit the draft's
body in the GitHub UI to replace the raw CHANGELOG with prose release
notes — thematic grouping, highlights, links to docs, etc. The existing
`tools/release/gen_release_notes.py` script (an AI-prompt generator for
exactly this authoring step) is retained for this workflow. It is
deliberately not invoked from CI: the release notes are a curated
artifact, and the cost of a bad auto-generated announcement is higher
than the cost of one manual editing step per release.
### LUCI → GitHub bridge
The tag-push → LUCI path already works today. The missing piece is notifying
GitHub when LUCI is done so artifacts can be attached.
For v1 we deliberately avoid a webhook. Instead, button 3 (`finalize-release`)
is a manually-triggered GH Actions workflow that a human clicks once LUCI is
green. It reads from the existing `gs://perfetto-luci-artifacts/` paths
(which are world-readable via `storage.cloud.google.com`) and uploads into
the draft release. This keeps the secret surface minimal — GH Actions already
has `GITHUB_TOKEN`, and no GCS credential is needed since the bucket is
public — at the cost of one extra click per release.
### UI: `channels.json` removal
The `channels.json` file and the associated `ui-canary` / `ui-stable` branches
go away. The UI Cloud Build trigger is reconfigured to build:
- `autopush` channel on every push to `main` (as today).
- `canary` channel on every push to `canary`.
- `stable` channel on every push to `stable`.
Because `canary` and `stable` are protected branches, there is no way to
accidentally redirect a channel without going through a PR, which is a
strictly stronger guarantee than `channels.json` provides today.
The UI version string (`vX.Y-<SHA>`) continues to be computed from `CHANGELOG`
+ branch HEAD SHA at build time, so it remains meaningful on every channel.
`channels.json` is not a public API, but as it has lived in the repo for a
long time the removal will be called out in the CHANGELOG and in the first
release's GitHub release notes.
### Python packaging
For v1, the PyPI release is a pure-Python wheel (no bundled
`trace_processor_shell`) on stable tags only. The version is auto-derived
from `CHANGELOG`, killing the hardcoded `0.16.0` in `python/setup.py`.
Bundling `trace_processor_shell` into the wheel — so that `pip install
perfetto` gives you a working CLI — is an attractive future step but has
non-trivial packaging implications (multi-platform wheels, LUCI artifact →
PyPI pipeline, manylinux compatibility). Out of scope for v1.
### What happens to existing scripts
- `tools/release/release_perfetto.py`: deleted. Its responsibilities are
split across the four GH Actions workflows.
- `tools/release/release_python.py`: deleted. Replaced by `publish-pypi.yml`.
- `tools/release/gen_release_notes.py`: retained. Invoked by the draft-release
workflow if `--generate-notes` is insufficient.
- `tools/release/package-github-release-artifacts`: retained, invoked from
LUCI builders to produce the per-platform zips.
- `tools/release/roll-prebuilts`: unchanged (separate concern: rolling
prebuilts into the repo, not cutting a release).
### Protected branch configuration
- `main`, `canary`, `stable`: all require PR, no direct push, no
force-push, require status checks.
- `canary` and `stable` additionally require a review from a designated
release-approver group (to prevent drive-by cherry-picks).
- The `vX.Y` tag namespace is restricted to being created by the
`promote-stable.yml` workflow's GH App / token (not by humans).
## Alternatives considered
### Keep `releases/vX.x` maintenance branches
Pro:
* Familiar. Allows in-theory backporting.
Con:
* We don't actually backport in practice — the last non-trivial backport was
years ago.
* Forces the human to decide "is this a new major or minor" at release time,
which has no meaning for how we actually develop.
* Branches accumulate indefinitely; the repo has 15+ of them already.
### Keep `channels.json`
Pro:
* Already works.
* Channel redirection is a single-file PR.
Con:
* Indirection: `ui-stable` branch exists but is not the source of truth.
* Two PRs for every cherry-pick.
* Different mental model from the SDK side of the release.
### LUCI → GitHub via webhook instead of manual finalize button
Pro:
* One fewer button click.
* Release publishes automatically the moment the last platform finishes.
Con:
* Requires standing up a Cloud Function (or similar) with a GitHub App
credential.
* Adds a new failure mode (webhook delivery).
* v1 has humans in the loop anyway for the two earlier buttons, so adding
one more at the end is marginal.
Revisit once the rest of the flow is proven.
### Tag on canary cut instead of on stable promotion
Pro:
* Artifacts would be buildable on canary, giving us pre-release binaries.
Con:
* Two tags per release (`vX.Y-canary`, `vX.Y`) is more machinery.
* LUCI builds are expensive; building twice per release doubles the spend for
marginal benefit.
* The UI already provides a canary channel with real user exposure; we don't
need binary prereleases.
### Monorepo-style unified version (e.g. `54.0.0` everywhere)
Pro:
* Uniform across SDK, UI, Python.
Con:
* Breaks existing `vX.Y` convention and existing consumers' parsers.
* Python's `X.Y.Z` semantics don't quite map (we never ship `.Z` patch
versions).
Keep the existing `vX.Y` for SDK/UI; map it to `0.X.Y` for Python.
## Open questions
None at this time.