ci: support releases from non-main branches (#4794)

Closes #4793

- Add `workflow_dispatch` trigger to CI (with macOS/Windows support)
- Allow prepare-release and release workflows from any branch
- Add `skip-ci-check` input for hotfix releases
- Add `just release::prepare` and `just ci` recipes
- Make `qt/release/build.sh` find uv in CI and local builds
- Change publish-testpypi environment from testpypi to release
- Add anki-release wheel build step

---------

Co-authored-by: Andrew Sanchez <andrewsanchez@users.noreply.github.com>
Co-authored-by: Fernando Lins <1887601+fernandolins@users.noreply.github.com>
This commit is contained in:
Andrew Sanchez
2026-05-07 14:46:15 -04:00
committed by GitHub
parent 5a9b54e938
commit f9570d31c2
7 changed files with 99 additions and 78 deletions

View File

@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
@@ -146,6 +147,7 @@ jobs:
check-macos:
if: >-
github.event_name == 'push'
|| github.event_name == 'workflow_dispatch'
|| contains(github.event.pull_request.labels.*.name, 'check:macos')
runs-on: macos-latest
name: check (macos)
@@ -209,6 +211,7 @@ jobs:
check-windows:
if: >-
github.event_name == 'push'
|| github.event_name == 'workflow_dispatch'
|| contains(github.event.pull_request.labels.*.name, 'check:windows')
runs-on: windows-latest
name: check (windows)

View File

@@ -8,6 +8,11 @@ on:
description: "Version (e.g. 26.04 for stable, 26.04b1 or 26.04rc1 for pre-release)"
required: true
type: string
skip-ci-check:
description: "Skip the CI status check (for hotfix releases from non-main branches)"
default: false
required: false
type: boolean
permissions:
contents: write
@@ -31,14 +36,6 @@ jobs:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Require dispatch from main
if: github.ref_name != 'main'
run: |
echo "::error::Must be dispatched from main. Got ref $REF_NAME."
exit 1
env:
REF_NAME: ${{ github.ref_name }}
- name: Validate version
run: |
pip install --break-system-packages 'packaging>=24,<26'
@@ -49,15 +46,15 @@ jobs:
env:
INPUT_VERSION: ${{ inputs.version }}
- name: Check CI passed on main
- name: Check CI passed
if: inputs.skip-ci-check != true
run: |
conclusion=$(gh run list \
--workflow=ci.yml \
-e push \
--commit "$COMMIT_SHA" \
--limit 1 \
--json conclusion \
--jq ".[0].conclusion")
--limit 5 \
--json conclusion,event \
--jq '[.[] | select(.event == "push" or .event == "workflow_dispatch")][0].conclusion')
if [[ -z "$conclusion" ]]; then
echo "::error::Could not determine CI status for commit $COMMIT_SHA"
@@ -103,5 +100,7 @@ jobs:
env:
INPUT_VERSION: ${{ inputs.version }}
- name: Push to main
run: git push origin HEAD:refs/heads/main
- name: Push to branch
run: git push origin HEAD:refs/heads/$BRANCH
env:
BRANCH: ${{ github.ref_name }}

View File

@@ -5,9 +5,14 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version for draft release/PyPI (must match .version on main)"
description: "Version for draft release/PyPI (must match .version)"
required: true
type: string
skip-ci-check:
description: "Skip the CI status check (for hotfix releases from non-main branches)"
default: false
required: false
type: boolean
sign:
description: "Sign macOS and Windows artifacts (requires release environment)"
default: false
@@ -107,26 +112,15 @@ jobs:
DRAFT_RELEASE: ${{ inputs['draft-release'] }}
PUBLISH_PYPI: ${{ inputs['publish-pypi'] }}
- name: Require dispatch from main
if: ${{ steps.validate.outputs.public_release == 'true' }}
run: |
if [ "$REF_NAME" != "main" ]; then
echo "::error::Releases must be dispatched from main. Got $REF_NAME."
exit 1
fi
env:
REF_NAME: ${{ github.ref_name }}
- name: Check CI passed on main
if: ${{ steps.validate.outputs.public_release == 'true' }}
- name: Check CI passed
if: ${{ steps.validate.outputs.public_release == 'true' && inputs.skip-ci-check != true }}
run: |
conclusion=$(gh run list \
--workflow=ci.yml \
-e push \
--commit "$COMMIT_SHA" \
--limit 1 \
--json conclusion \
--jq ".[0].conclusion")
--limit 5 \
--json conclusion,event \
--jq '[.[] | select(.event == "push" or .event == "workflow_dispatch")][0].conclusion')
if [[ -z "$conclusion" ]]; then
echo "::error::Could not determine CI status for commit $COMMIT_SHA"
@@ -401,6 +395,9 @@ jobs:
- name: Build wheels
run: ./ninja wheels
- name: Build anki-release wheel
run: cd qt/release && bash build.sh
- uses: actions/upload-artifact@v4
with:
name: wheels-linux-x86
@@ -519,7 +516,7 @@ jobs:
}}
runs-on: ubuntu-latest
timeout-minutes: 15
environment: testpypi
environment: release
permissions:
id-token: write
steps:

View File

@@ -3,8 +3,10 @@
Releases are managed by two GitHub Actions workflows under `.github/workflows/`:
1. **`prepare-release.yml`** — Run first. Validates the version, checks that CI
passed on main, syncs translations, updates `.version`, and pushes everything
to main in a single commit. Normal CI then runs on the resulting commit.
passed, syncs translations, updates `.version`, and pushes everything
to the dispatching branch in a single commit. Normal CI then runs on the
resulting commit. Can be dispatched from any branch (not just main) for
hotfix/security releases. The CI check can be skipped with `skip-ci-check`.
2. **`release.yml`** — Run after CI passes on the prepared commit. Builds
installers and wheels for all platforms (Linux x86/ARM, macOS Intel/ARM,
@@ -18,7 +20,7 @@ they cannot run simultaneously.
```mermaid
flowchart LR
A["<b>prepare-release.yml</b><br/>validate version<br/>check CI ✓<br/>check duplicate tag<br/>sync translations<br/>update .version<br/>push to main"] --> B["<b>CI (ci.yml)</b><br/>runs automatically<br/>on the new commit"]
A["<b>prepare-release.yml</b><br/>validate version<br/>check CI ✓<br/>check duplicate tag<br/>sync translations<br/>update .version<br/>push to branch"] --> B["<b>CI (ci.yml)</b><br/>runs automatically<br/>on the new commit"]
B --> C["<b>release.yml</b><br/>build all platforms<br/>optionally sign macOS/Windows<br/>optionally create draft GitHub release<br/>optionally publish to TestPyPI/PyPI"]
style A fill:#2d333b,stroke:#539bf5,color:#adbac7
@@ -82,20 +84,24 @@ suffixes (`b1`, `rc1`, `a1`). Months must be zero-padded.
## Workflow inputs
**prepare-release:** takes a `version` string.
**prepare-release:** takes a `version` string and an optional `skip-ci-check`
boolean (default `false`). Set `skip-ci-check=true` for hotfix releases from
non-main branches where CI was triggered via `workflow_dispatch` or hasn't run
on the branch yet.
**release:** takes a `version` (must match `.version` on main for public
release operations) and four boolean inputs:
**release:** takes a `version` (must match `.version` for public release
operations) and five boolean inputs:
- `sign` signs macOS and Windows artifacts.
- `draft-release` creates the draft GitHub release.
- `publish-testpypi` publishes wheels to TestPyPI.
- `publish-pypi` publishes wheels to PyPI.
- `skip-ci-check` skips the CI status check (for hotfix releases).
All four booleans default to `false`. Non-release runs use the `.version`
All booleans default to `false`. Non-release runs use the `.version`
already in the repo, so builds work without a prepare step.
For a normal public release, enable all four booleans: `sign=true`,
For a normal public release, enable the first four booleans: `sign=true`,
`draft-release=true`, `publish-testpypi=true`, and `publish-pypi=true`.
## Environment gates
@@ -105,11 +111,9 @@ The release workflow uses GitHub
as manual approval gates. Jobs that access signing credentials or publish
artifacts require a reviewer to approve the deployment before they run:
- **`release`** — Required when `sign`, `draft-release`, or `publish-pypi` is
enabled. Protects code-signing secrets, the release token, and PyPI trusted
publishing/OIDC.
- **`testpypi`** — Required by the TestPyPI publishing job. Allows test
uploads to be gated separately from production releases.
- **`release`** — Required when `sign`, `draft-release`, `publish-testpypi`, or
`publish-pypi` is enabled. Protects code-signing secrets, the release token,
and PyPI/TestPyPI trusted publishing/OIDC.
When `sign` is disabled, the macOS and Windows build jobs run without the
`release` environment so they do not require approval and cannot access signing
@@ -120,13 +124,14 @@ secrets.
The `release.yml` workflow uses independent boolean inputs to control what gets
signed and published:
| Input | Effect |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sign` | Signs macOS and Windows artifacts. Requires the `release` environment. When false, those jobs upload unsigned artifacts and do not access signing secrets. |
| `draft-release` | Creates a draft GitHub release with generated release notes and installer artifacts. Requires `sign=true`, the `release` environment, main branch, passing CI, no duplicate tag/release, and `version` matching `.version`. |
| `publish-testpypi` | Publishes wheels to TestPyPI. Requires the `testpypi` environment. |
| `publish-pypi` | Publishes wheels to PyPI. Requires the `release` environment, main branch, passing CI, and `version` matching `.version`. It also runs and waits for the TestPyPI publish job first. It does not require signing unless `draft-release=true`. |
| `version` | For `draft-release` or `publish-pypi`: must match `.version` on main. For build-only, signed-only, or TestPyPI-only runs: ignored (`.version` from the branch is used automatically). |
| Input | Effect |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sign` | Signs macOS and Windows artifacts. Requires the `release` environment. When false, those jobs upload unsigned artifacts and do not access signing secrets. |
| `draft-release` | Creates a draft GitHub release with generated release notes and installer artifacts. Requires `sign=true`, the `release` environment, passing CI (unless skipped), no duplicate tag/release, and `version` matching `.version`. |
| `publish-testpypi` | Publishes wheels to TestPyPI. Requires the `release` environment. |
| `publish-pypi` | Publishes wheels to PyPI. Requires the `release` environment, passing CI (unless skipped), and `version` matching `.version`. It also runs and waits for the TestPyPI publish job first. It does not require signing unless `draft-release=true`. |
| `skip-ci-check` | Skips the CI status check. Useful for hotfix releases from non-main branches where CI was run via `workflow_dispatch`. |
| `version` | For `draft-release` or `publish-pypi`: must match `.version`. For build-only, signed-only, or TestPyPI-only runs: ignored (`.version` from the branch is used automatically). |
```mermaid
flowchart TD
@@ -144,14 +149,14 @@ flowchart TD
unsigned --> artifacts
artifacts --> draft{"draft-release?"}
draft -- Yes --> guards1["Release guards\n<i>main, CI, duplicate check,\nversion matches .version</i>"]
draft -- Yes --> guards1["Release guards\n<i>CI (unless skipped), duplicate check,\nversion matches .version</i>"]
guards1 --> ghrel["Create draft GitHub release\n<i>requires release env</i>"]
artifacts --> testpypi{"publish-testpypi\nor publish-pypi?"}
testpypi -- Yes --> tpypi["Publish to TestPyPI\n<i>requires testpypi env</i>"]
testpypi -- Yes --> tpypi["Publish to TestPyPI\n<i>requires release env</i>"]
tpypi --> pypi{"publish-pypi?"}
pypi -- Yes --> guards2["Release guards\n<i>main, CI,\nversion matches .version</i>"]
pypi -- Yes --> guards2["Release guards\n<i>CI (unless skipped),\nversion matches .version</i>"]
ghrel --> pypi
guards2 --> realpypi["Publish to PyPI\n<i>requires release env</i>"]
@@ -166,16 +171,8 @@ Release workflows can be dispatched via `just` using the `release` module
defined in `release.just`. All recipes read the version from `.version`
automatically.
| Command | Description |
| ------------------------------------------------------------ | ---------------------------------------------------- |
| `just release::build` | Build-only from HEAD (all booleans false) |
| `just release::build <ref>` | Build-only from a specific branch |
| `just release::sign` | Build and sign from HEAD |
| `just release::sign <ref>` | Build and sign from a specific branch |
| `just release::public` | Full release from main (sign, draft, testpypi, pypi) |
| `just release::custom <ref> sign=true publish-testpypi=true` | Mix and match flags |
Run `just --list --list-submodules` to see all available recipes.
Run `just --list --list-submodules` to see all available recipes and their
arguments.
## Testing the release workflow from a feature branch
@@ -211,13 +208,27 @@ To test the signing flow from a feature branch:
> modified on your branch, use `gh workflow run` to trigger it — the UI
> dropdown won't show it until it's merged to main.
`prepare-release.yml` cannot be tested from a non-main branch — it
unconditionally requires `main`. To validate its scripts locally, run:
### Hotfix / security releases from non-main branches
```
pip install 'packaging>=24,<26'
python3 .github/scripts/validate_version.py <version> <current_version>
```
Both `prepare-release.yml` and `release.yml` can be dispatched from any branch.
For a hotfix release (e.g. from a `25.09.3` branch):
1. Trigger CI on the branch:
```
just ci 25.09.3
```
2. Prepare the release (skip CI check if CI was triggered via `workflow_dispatch`):
```
just release::prepare 25.09.3 skip-ci-check=true
```
3. Run the release workflow:
```
just release::public 25.09.3
```
Or with `skip-ci-check`:
```
just release::custom 25.09.3 sign=true draft-release=true publish-testpypi=true publish-pypi=true skip-ci-check=true
```
## Important notes

View File

@@ -73,5 +73,9 @@ docs-serve:
docs-rust:
cargo doc --open
# Dispatch CI workflow on a given branch or tag
ci branch:
gh workflow run ci.yml --ref {{ branch }}
# Helper to get the right ninja command for the platform
ninja := if os() == "windows" { "tools\\ninja" } else { "./ninja" }

View File

@@ -10,8 +10,8 @@ test -f build.sh || {
# Get the project root (two levels up from qt/release)
PROJ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Use extracted uv binary
UV="$PROJ_ROOT/out/extracted/uv/uv"
# Use uv from PATH (CI), falling back to the extracted copy (local builds)
UV="${UV_BINARY:-$(command -v uv 2>/dev/null || echo "$PROJ_ROOT/out/extracted/uv/uv")}"
# Read version from .version file
VERSION=$(cat "$PROJ_ROOT/.version" | tr -d '[:space:]')

View File

@@ -2,6 +2,12 @@ set windows-shell := ["cmd.exe", "/c"]
version := `cat .version`
# Prepare a release: validate version, check CI, sync translations, update .version
prepare ref="HEAD" skip-ci-check="false":
gh workflow run prepare-release.yml --ref {{ ref }} \
-f version="{{ version }}" \
-f skip-ci-check={{ skip-ci-check }}
# Build all platforms, no signing or publishing
build ref="HEAD":
gh workflow run release.yml --ref {{ ref }} \
@@ -21,8 +27,8 @@ sign ref="HEAD":
-f publish-pypi=false
# Full public release: sign, draft, testpypi, pypi
public:
gh workflow run release.yml --ref main \
public ref="main":
gh workflow run release.yml --ref {{ ref }} \
-f version="{{ version }}" \
-f sign=true \
-f draft-release=true \
@@ -30,10 +36,11 @@ public:
-f publish-pypi=true
# Custom release with explicit flags
custom ref="HEAD" sign="false" draft-release="false" publish-testpypi="false" publish-pypi="false":
custom ref="HEAD" sign="false" draft-release="false" publish-testpypi="false" publish-pypi="false" skip-ci-check="false":
gh workflow run release.yml --ref {{ ref }} \
-f version="{{ version }}" \
-f sign={{ sign }} \
-f draft-release={{ draft-release }} \
-f publish-testpypi={{ publish-testpypi }} \
-f publish-pypi={{ publish-pypi }}
-f publish-pypi={{ publish-pypi }} \
-f skip-ci-check={{ skip-ci-check }}