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
213 changes: 156 additions & 57 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,26 @@
# A Desktop pull request that should ship bumps the version and the changelog
# itself (packages/desktop/scripts/prepare_release.py); the desktop
# release-intent check in packages.yml fails a feat or fix that does not. There
# is no bot version commit. When main changes packages/desktop/pyproject.toml:
# is no bot version commit.
#
# gate stops quietly if this version is already released; waits for
# the commit's Packages run and requires its desktop test and
# build jobs to have passed; refuses if packages/desktop changed
# on main since.
# A release starts when Packages finishes successfully for a push to main
# (workflow_run), so time spent queued behind other CI cannot fail it. That
# Packages commit is the subject: its checks decide the release. GitHub runs
# this workflow, and signs its attestations, at main's tip (github.sha), so the
# release builds and tags that tip and requires its packages/desktop tree to
# equal the subject's. A manual dispatch does the same with the newest
# first-parent commit that changed Desktop as the subject, and waits for that
# commit's Packages run.
#
# gate stops quietly when the subject does not change
# packages/desktop, when a newer Desktop change on main will
# release itself, or when this version is already released;
# requires the subject's Packages run to have passed with its
# desktop test and build jobs; refuses if packages/desktop
# changed on main since a dispatched subject.
# build-* the Python distributions and every native installer, smoke-
# tested. The macOS and Windows jobs run in the native-release
# environment (its reviewer approves the release; it holds the
# signing secrets).
# environment, which holds the signing secrets.
# attest checksums, SBOM, provenance, and this release's channel (v3,
# naming the previous release's channel by hash), all attested.
# stage the openadapt-release App creates the tag and a draft with
Expand All @@ -38,10 +48,10 @@
name: Desktop release

on:
push:
workflow_run:
workflows: [Packages]
types: [completed]
branches: [main]
paths:
- packages/desktop/pyproject.toml
workflow_dispatch:

permissions:
Expand All @@ -53,22 +63,42 @@ defaults:

concurrency:
# One transaction owns the tag and the release. Never run two at once.
# Every Packages run on main starts a run here, most of them no-ops; without
# a queue, each new pending run would cancel the release waiting before it.
group: desktop-release
cancel-in-progress: false
queue: max

jobs:
gate:
name: Decide whether this commit releases a new Desktop version
if: github.ref == 'refs/heads/main'
# workflow_run fires for every Packages run whose head branch is main,
# including a fork's pull request from a branch named main. Only a
# successful push run on this repository's main can release.
if: >-
github.ref == 'refs/heads/main' && (
github.event_name == 'workflow_dispatch' || (
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository
)
)
runs-on: ubuntu-22.04
timeout-minutes: 75
# A dispatch may wait four hours for a queued Packages run, then 60 minutes
# for it to finish.
timeout-minutes: 320
permissions:
actions: read
contents: read
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PACKAGES_RUN_ID: ${{ github.event.workflow_run.id }}
PACKAGES_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
outputs:
release: ${{ steps.state.outputs.release }}
release: ${{ steps.fresh.outputs.release }}
version: ${{ steps.state.outputs.version }}
tag: ${{ steps.state.outputs.tag }}
sbom: ${{ steps.state.outputs.sbom }}
Expand All @@ -82,7 +112,44 @@ jobs:
with:
python-version: "3.12"

- name: Choose the commit whose checks decide the release
id: subject
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = workflow_run ]; then
# The Packages commit, never main's tip: its checks are the ones
# that finished.
subject="${PACKAGES_HEAD_SHA}"
if ! [[ "${subject}" =~ ^[0-9a-f]{40}$ ]]; then
echo "Packages head commit '${subject}' is not a commit id." >&2
exit 1
fi
git merge-base --is-ancestor "${subject}" "${GITHUB_SHA}"
echo "sha=${subject}" >> "${GITHUB_OUTPUT}"
if git diff --quiet "${subject}^1" "${subject}" -- .; then
echo "No release: ${subject} does not change packages/desktop."
echo "candidate=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
if ! git diff --quiet "${subject}" "${GITHUB_SHA}" -- .; then
newer="$(git log -1 --first-parent --format=%H "${GITHUB_SHA}" -- .)"
echo "No release: main changed packages/desktop again at ${newer}; that commit's Packages run starts its own release."
echo "candidate=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
else
# A dispatch builds the tip; its checks are those of the newest
# commit on main's own history (first parent) that changed
# Desktop, whose tree the tip must equal. A commit from inside a
# merged branch never had a Packages push run on main.
subject="$(git log -1 --first-parent --format=%H "${GITHUB_SHA}" -- .)"
echo "sha=${subject}" >> "${GITHUB_OUTPUT}"
fi
echo "Release checks follow ${subject}; the release builds ${GITHUB_SHA}."
echo "candidate=true" >> "${GITHUB_OUTPUT}"

- name: Read the version and the state of its release
if: steps.subject.outputs.candidate == 'true'
id: state
run: |
set -euo pipefail
Expand Down Expand Up @@ -139,33 +206,57 @@ jobs:
--candidate-tag "${TAG}" \
--tags remote-tags.txt

- name: Choose the commit whose checks decide the release
- name: Require that commit's Packages run and its desktop jobs to have passed
if: steps.state.outputs.release == 'true'
id: subject
env:
EVENT_NAME: ${{ github.event_name }}
SUBJECT: ${{ steps.subject.outputs.sha }}
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = push ]; then
subject="${GITHUB_SHA}"
else
# A dispatch builds the tip; its checks are those of the newest
# commit on main's own history (first parent) that changed
# Desktop, whose tree the tip must equal. A commit from inside a
# merged branch never had a Packages push run on main.
subject="$(git log -1 --first-parent --format=%H "${GITHUB_SHA}" -- .)"
packages_workflow="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/packages.yml" --jq .id)"
require_desktop_jobs() {
# A green run with the desktop jobs skipped proves nothing about
# Desktop. Require that both desktop workflows ran and passed.
gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/$1/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.name)"' > "${RUNNER_TEMP}/jobs.tsv"
for prefix in "desktop test / " "desktop build / "; do
if ! grep -q $'^success\t'"${prefix}" "${RUNNER_TEMP}/jobs.tsv"; then
echo "Refusing to release: no successful '${prefix}' job in run $1." >&2
exit 1
fi
done
if grep -E $'^(failure|cancelled|timed_out)\tdesktop (test|build) / ' "${RUNNER_TEMP}/jobs.tsv"; then
echo "Refusing to release: a desktop job did not pass." >&2
exit 1
fi
}

if [ "${EVENT_NAME}" = workflow_run ]; then
# The Packages run that started this one has finished. Read it back
# instead of trusting the event: it must be this repository's push
# run on main for the subject, and it must have succeeded.
run="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${PACKAGES_RUN_ID}" \
--jq '"\(.workflow_id) \(.head_sha) \(.event) \(.head_branch) \(.head_repository.full_name) \(.status) \(.conclusion)"')"
echo "Packages run ${PACKAGES_RUN_ID}: ${run}"
read -r workflow_id head_sha event branch repository status conclusion <<<"${run}"
if [ "${workflow_id}" != "${packages_workflow}" ] || [ "${head_sha}" != "${SUBJECT}" ] || \
[ "${event}" != push ] || [ "${branch}" != main ] || [ "${repository}" != "${GITHUB_REPOSITORY}" ]; then
echo "Refusing to release: run ${PACKAGES_RUN_ID} is not the Packages push run for ${SUBJECT} on main." >&2
exit 1
fi
if [ "${status}" != completed ] || [ "${conclusion}" != success ]; then
echo "Refusing to release: Packages is ${status}/${conclusion}." >&2
exit 1
fi
require_desktop_jobs "${PACKAGES_RUN_ID}"
exit 0
fi
echo "Release checks follow ${subject}."
echo "sha=${subject}" >> "${GITHUB_OUTPUT}"

- name: Wait for that commit's Packages run and its desktop jobs
if: steps.state.outputs.release == 'true'
env:
SUBJECT: ${{ steps.subject.outputs.sha }}
run: |
set -euo pipefail
deadline=$((SECONDS + 3600))
while [ "${SECONDS}" -lt "${deadline}" ]; do
# A dispatch waits for the subject's Packages push run: up to four
# hours while it is queued, then up to 60 minutes once it runs.
# Congested CI can hold a run queued for over an hour.
queued_deadline=$((SECONDS + 4 * 3600))
running_deadline=
while :; do
run="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/workflows/packages.yml/runs" \
--raw-field head_sha="${SUBJECT}" \
Expand All @@ -176,34 +267,35 @@ jobs:
| "\(.id // 0) \(.status // "missing") \(.conclusion // "pending")"')"
echo "Packages for ${SUBJECT}: ${run}"
read -r run_id status conclusion <<<"${run}"
if [ "${status}" = completed ]; then
if [ "${conclusion}" != success ]; then
echo "Refusing to release: Packages concluded ${conclusion}." >&2
exit 1
fi
# A green run with the desktop jobs skipped proves nothing about
# Desktop. Require that both desktop workflows ran and passed.
gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.name)"' > "${RUNNER_TEMP}/jobs.tsv"
for prefix in "desktop test / " "desktop build / "; do
if ! grep -q $'^success\t'"${prefix}" "${RUNNER_TEMP}/jobs.tsv"; then
echo "Refusing to release: no successful '${prefix}' job in run ${run_id}." >&2
case "${status}" in
completed)
if [ "${conclusion}" != success ]; then
echo "Refusing to release: Packages concluded ${conclusion}." >&2
exit 1
fi
done
if grep -E $'^(failure|cancelled|timed_out)\tdesktop (test|build) / ' "${RUNNER_TEMP}/jobs.tsv"; then
echo "Refusing to release: a desktop job did not pass." >&2
exit 1
fi
exit 0
fi
sleep 20
require_desktop_jobs "${run_id}"
exit 0
;;
in_progress)
running_deadline="${running_deadline:-$((SECONDS + 3600))}"
if [ "${SECONDS}" -ge "${running_deadline}" ]; then
echo "Refusing to release: Packages did not finish within 60 minutes of starting." >&2
exit 1
fi
;;
*)
if [ "${SECONDS}" -ge "${queued_deadline}" ]; then
echo "Refusing to release: Packages is still ${status} after four hours." >&2
exit 1
fi
;;
esac
sleep 30
done
echo "Refusing to release: Packages did not succeed within 60 minutes." >&2
exit 1

- name: Require that main has not changed Desktop since that commit
if: steps.state.outputs.release == 'true'
id: fresh
env:
SUBJECT: ${{ steps.subject.outputs.sha }}
run: |
Expand All @@ -213,11 +305,18 @@ jobs:
git merge-base --is-ancestor "${GITHUB_SHA}" refs/remotes/origin/main
for later in refs/remotes/origin/main "${GITHUB_SHA}"; do
if ! git diff --quiet "${SUBJECT}" "${later}" -- .; then
if [ "${EVENT_NAME}" = workflow_run ]; then
# The newer commit's own Packages run starts its release.
echo "No release: packages/desktop changed on main after ${SUBJECT}; the newer change releases itself."
echo "release=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
echo "packages/desktop changed on main after ${SUBJECT}." >&2
echo "The newer change releases itself; dispatch this workflow once its checks pass." >&2
echo "The newer change releases itself once its Packages run passes." >&2
exit 1
fi
done
echo "release=true" >> "${GITHUB_OUTPUT}"

build-python:
name: Python distributions
Expand Down
8 changes: 4 additions & 4 deletions docs/monorepo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ A folded package releases from here: run the `Release package` workflow from
repository.

openadapt-desktop releases differently, because its native installers belong in
the same release: when a reviewed version bump merges, `desktop-release.yml`
builds the wheel, the sdist and every installer, then publishes one immutable
`openadapt-desktop-vX.Y.Z` release and the PyPI package (see
`packages/desktop/RELEASES.md`). Its Production channel and FFmpeg runtime
the same release: when a reviewed version bump merges and its Packages run on
main succeeds, `desktop-release.yml` builds the wheel, the sdist and every
installer, then publishes one immutable `openadapt-desktop-vX.Y.Z` release and
the PyPI package (see `packages/desktop/RELEASES.md`). Its Production channel and FFmpeg runtime
lanes have not moved.

A source repository is archived, never deleted, after its package has
Expand Down
7 changes: 4 additions & 3 deletions packages/desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ perf: short description -> patch bump
A pull request with a `feat`, `fix` or `perf` commit must bump the version, or
the `desktop release intent` check fails. Run
`python scripts/prepare_release.py` in `packages/desktop`, review the new
`CHANGELOG.md` section, and commit. After merge, `desktop-release.yml` builds
the wheel, the sdist and every installer, and publishes one
`openadapt-desktop-vX.Y.Z` release and the PyPI package. See RELEASES.md.
`CHANGELOG.md` section, and commit. After merge, once Packages passes on main,
`desktop-release.yml` builds the wheel, the sdist and every installer, and
publishes one `openadapt-desktop-vX.Y.Z` release and the PyPI package. See
RELEASES.md.

---

Expand Down
47 changes: 40 additions & 7 deletions packages/desktop/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ launcher's.
or `perf` commit under `packages/desktop` that keeps the version, and prints
the command above. It also requires every version source, native included,
to carry the one version.
3. When the bump merges, `.github/workflows/desktop-release.yml` waits for that
exact commit's Packages run (its desktop test and build jobs must have
passed), then builds the wheel, the sdist and every installer and
smoke-tests them. The `native-release` environment's reviewer approves the
signing jobs. It attests the exact bytes and writes the channel, which binds
`SHA256SUMS` and the provenance by hash, states each platform's signing
mode, and names the previous release's channel by hash.
3. When the bump merges and its Packages run on main succeeds,
`.github/workflows/desktop-release.yml` starts (see "How a release starts"
below). It requires that run's desktop test and build jobs to have passed,
then builds the wheel, the sdist and every installer and smoke-tests them.
The signing jobs run in the `native-release` environment, which holds the
signing secrets. The workflow attests the exact bytes and writes the
channel, which binds `SHA256SUMS` and the provenance by hash, states each
platform's signing mode, and names the previous release's channel by hash.
4. The openadapt-release App creates the tag and a draft release with every
asset and verifies the draft's bytes. PyPI then gets the same wheel and sdist
through Trusted Publishing, and the draft is published once. If any build
Expand All @@ -36,6 +37,38 @@ launcher's.
ships unsigned. File names, the website manifest, the channel and the
release notes all carry each mode.

### How a release starts

A release starts from `workflow_run`: Packages completing with success for a
push to main. Time that Packages spends queued behind other CI can't fail a
release, because the release doesn't start until Packages has finished.

- **Subject.** The Packages run's head commit (`workflow_run.head_sha`) is the
subject. Its checks decide the release; the gate reads that run back and
requires it to be this repository's successful push run on main for that
commit, with successful `desktop test / ` and `desktop build / ` jobs.
- **Built commit.** GitHub runs a `workflow_run` workflow, and signs its
attestations, at main's tip (`github.sha`). The release builds and tags that
tip, so the provenance's workflow commit and source commit stay equal. The
gate requires the tip's `packages/desktop` tree to equal the subject's.
- **No release.** The run ends quietly, with nothing built, when the subject
doesn't change `packages/desktop`, when main changed `packages/desktop`
again after the subject (that newer commit's Packages run starts its own
release), or when the version is already released. When Packages fails, the
gate is skipped.
- **Order.** Every Packages run on main starts a Desktop release run, and most
end as no release. The `desktop-release` concurrency group queues runs
(`queue: max`) instead of letting a newer pending run cancel an older one.

A manual run (`gh workflow run desktop-release.yml --ref main`) builds the tip
and takes as its subject the newest commit on main's first-parent history that
changed `packages/desktop`. It waits for that commit's Packages push run: up to
four hours while the run is queued, then up to 60 minutes once it starts.

Every other gate applies to both triggers: version, changelog and tag order,
the release App identity for tagging, and a refusal to rebuild a partly
published version.

Consumers pick the newest valid `openadapt-desktop-v*` release; there is no
standing channel release, and nothing is mirrored between releases.

Expand Down
Loading
Loading