diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b20d9222d..180606c0c 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 @@ -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: @@ -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 }} @@ -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 @@ -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}" \ @@ -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: | @@ -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 diff --git a/docs/monorepo/README.md b/docs/monorepo/README.md index 903e217c7..b97b519e4 100644 --- a/docs/monorepo/README.md +++ b/docs/monorepo/README.md @@ -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 diff --git a/packages/desktop/CLAUDE.md b/packages/desktop/CLAUDE.md index 37d8a0810..a38da3b3b 100644 --- a/packages/desktop/CLAUDE.md +++ b/packages/desktop/CLAUDE.md @@ -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. --- diff --git a/packages/desktop/RELEASES.md b/packages/desktop/RELEASES.md index 02dadb85d..cd96188fb 100644 --- a/packages/desktop/RELEASES.md +++ b/packages/desktop/RELEASES.md @@ -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 @@ -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. diff --git a/packages/desktop/scripts/native_release.py b/packages/desktop/scripts/native_release.py index d3baf61c9..88c698bda 100644 --- a/packages/desktop/scripts/native_release.py +++ b/packages/desktop/scripts/native_release.py @@ -70,7 +70,9 @@ # paths name the root file. NATIVE_RELEASE_WORKFLOW = ".github/workflows/desktop-release.yml" NATIVE_RELEASE_WORKFLOW_NAME = "Desktop release" -NATIVE_RELEASE_EVENTS = frozenset({"push", "workflow_dispatch"}) +# Releases start from a successful Packages run on main (workflow_run) or a +# dispatch; "push" stays valid for the releases made before that. +NATIVE_RELEASE_EVENTS = frozenset({"push", "workflow_dispatch", "workflow_run"}) DEFAULT_REPOSITORY = "OpenAdaptAI/OpenAdapt" VERIFIED_RELEASE_CHANNEL = "openadapt-desktop-channel.json" NATIVE_RELEASE_CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v3" @@ -1022,7 +1024,8 @@ def _validate_attestation_record(record: object, *, provenance: dict, checksums: commit = provenance["source_commit"] run_id = provenance["run_id"] run_attempt = provenance["run_attempt"] - # A release runs on the push that merged the version, or on a dispatch. + # A release runs when Packages passes on main, or on a dispatch; releases + # before 0.22.0 ran on the push that merged the version. trigger = provenance["event"] repository_url = f"https://github.com/{repository}" workflow_uri = f"{repository_url}/{NATIVE_RELEASE_WORKFLOW}@refs/heads/main" diff --git a/packages/desktop/scripts/verify_native_release_download.py b/packages/desktop/scripts/verify_native_release_download.py index 8b4ed5581..8e34b678d 100644 --- a/packages/desktop/scripts/verify_native_release_download.py +++ b/packages/desktop/scripts/verify_native_release_download.py @@ -28,7 +28,7 @@ # installers, and the channel. NATIVE_TAG_PREFIX = "openadapt-desktop-v" NATIVE_RELEASE_WORKFLOW = ".github/workflows/desktop-release.yml" -RELEASE_EVENTS = frozenset({"push", "workflow_dispatch"}) +RELEASE_EVENTS = frozenset({"push", "workflow_dispatch", "workflow_run"}) GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" CHANNEL_NAME = "openadapt-desktop-channel.json" CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v3" diff --git a/packages/desktop/tests/test_native_release.py b/packages/desktop/tests/test_native_release.py index 7b8ea404e..7e59dd5d8 100644 --- a/packages/desktop/tests/test_native_release.py +++ b/packages/desktop/tests/test_native_release.py @@ -110,14 +110,19 @@ def test_native_workflows_are_pinned_and_preserve_candidate_boundary() -> None: assert all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", use) for use in uses) trigger = release[True] - assert set(trigger) == {"push", "workflow_dispatch"} - assert trigger["push"] == { + assert set(trigger) == {"workflow_run", "workflow_dispatch"} + assert trigger["workflow_run"] == { + "workflows": ["Packages"], + "types": ["completed"], "branches": ["main"], - "paths": ["packages/desktop/pyproject.toml"], } assert release["permissions"] == {"contents": "read"} assert release["defaults"] == {"run": {"working-directory": "packages/desktop"}} - assert release["concurrency"] == {"group": "desktop-release", "cancel-in-progress": False} + assert release["concurrency"] == { + "group": "desktop-release", + "cancel-in-progress": False, + "queue": "max", + } jobs = release["jobs"] assert set(jobs) == { @@ -131,7 +136,7 @@ def test_native_workflows_are_pinned_and_preserve_candidate_boundary() -> None: "publish-pypi", "publish", } - assert jobs["gate"]["if"] == "github.ref == 'refs/heads/main'" + assert jobs["gate"]["if"].startswith("github.ref == 'refs/heads/main' && (") for name in ("build-python", "build-macos", "build-windows", "build-linux"): assert jobs[name]["needs"] == "gate" assert jobs[name]["if"] == "needs.gate.outputs.release == 'true'" diff --git a/packages/desktop/tests/test_public_metadata.py b/packages/desktop/tests/test_public_metadata.py index 98be8e709..1774b52f3 100644 --- a/packages/desktop/tests/test_public_metadata.py +++ b/packages/desktop/tests/test_public_metadata.py @@ -181,14 +181,20 @@ def test_monorepo_engine_release_publishes_only_a_green_reviewed_version() -> No gate = workflow[workflow.index("\n gate:") : workflow.index("\n build-python:")] state = gate.index("- name: Read the version and the state of its release") subject = gate.index("- name: Choose the commit whose checks decide the release") - wait = gate.index("- name: Wait for that commit's Packages run and its desktop jobs") + wait = gate.index( + "- name: Require that commit's Packages run and its desktop jobs to have passed" + ) unchanged = gate.index("- name: Require that main has not changed Desktop since that commit") assert ( - " push:\n branches: [main]\n paths:\n - packages/desktop/pyproject.toml" - in triggers + " workflow_run:\n workflows: [Packages]\n types: [completed]\n" + " branches: [main]" in triggers ) - assert state < subject < wait < unchanged + assert " push:" not in triggers + # The subject is classified first, so an unrelated commit never reaches + # the release-state check. + assert subject < state < wait < unchanged + assert 'subject="${PACKAGES_HEAD_SHA}"' in gate assert "https://pypi.org/pypi/openadapt-desktop/${version}/json" in gate assert "partly published" in gate assert "actions/workflows/packages.yml/runs" in gate diff --git a/tests/test_desktop_release_subject.py b/tests/test_desktop_release_subject.py index cf98b4d9a..562d23eda 100644 --- a/tests/test_desktop_release_subject.py +++ b/tests/test_desktop_release_subject.py @@ -1,17 +1,102 @@ -"""A dispatched Desktop release waits on a commit that has its own checks. +"""How a Desktop release chooses its commit and the checks that decide it. -Only commits on main's first-parent history get a Packages push run. A commit -from inside a merged branch never does, so choosing it made the release gate -wait 60 minutes for a run that cannot exist, then refuse (2026-10-20, 0.21.1). +A release starts when Packages finishes successfully for a push to main +(workflow_run). It used to start on the push and poll for that commit's +Packages run for 60 minutes; under CI congestion the run sat queued longer than +that and release run 39368396653 refused 0.22.0 (2026-10-20). + +The Packages head commit is the subject: its checks decide the release. +GitHub runs a workflow_run workflow, and signs its attestations, at main's tip, +so the release builds the tip and requires its packages/desktop tree to equal +the subject's. A dispatch picks the newest first-parent commit that changed +Desktop. Only commits on main's first-parent history get a Packages push run; +choosing one from inside a merged branch made the gate wait for a run that +could not exist (2026-10-20, 0.21.1). """ from __future__ import annotations from pathlib import Path +import yaml + WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/desktop-release.yml" +def _workflow() -> dict: + payload = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + assert isinstance(payload, dict) + return payload + + +def _gate() -> dict: + return _workflow()["jobs"]["gate"] + + +def _gate_steps() -> dict[str, dict]: + return { + str(step.get("name") or step.get("uses")): step for step in _gate()["steps"] + } + + +SUBJECT_STEP = "Choose the commit whose checks decide the release" +STATE_STEP = "Read the version and the state of its release" +ORDER_STEP = "Require one version everywhere, its changelog, and a new tag in order" +PACKAGES_STEP = "Require that commit's Packages run and its desktop jobs to have passed" +FRESH_STEP = "Require that main has not changed Desktop since that commit" + + +def test_a_release_starts_when_packages_completes_on_main() -> None: + trigger = _workflow()[True] + # No push trigger: a push-triggered run had to wait for Packages, and its + # queue time could fail the release. + assert set(trigger) == {"workflow_run", "workflow_dispatch"} + assert trigger["workflow_run"] == { + "workflows": ["Packages"], + "types": ["completed"], + "branches": ["main"], + } + + +def test_only_a_successful_push_run_on_this_repositorys_main_can_release() -> None: + condition = " ".join(_gate()["if"].split()) + assert condition.startswith("github.ref == 'refs/heads/main' && (") + assert "github.event_name == 'workflow_dispatch' ||" in condition + for clause in ( + "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", + ): + assert f"{clause} &&" in condition or f"{clause} )" in condition, clause + + +def test_a_pending_release_is_queued_not_cancelled() -> None: + # Every Packages run on main starts a run here. Without a queue, a newer + # pending run cancels the release waiting before it. + assert _workflow()["concurrency"] == { + "group": "desktop-release", + "cancel-in-progress": False, + "queue": "max", + } + + +def test_the_subject_is_the_packages_head_commit_never_mains_tip() -> None: + gate = _gate() + assert ( + gate["env"]["PACKAGES_HEAD_SHA"] == "${{ github.event.workflow_run.head_sha }}" + ) + assert gate["env"]["PACKAGES_RUN_ID"] == "${{ github.event.workflow_run.id }}" + run = _gate_steps()[SUBJECT_STEP]["run"] + workflow_run, _ = run.split("\nelse\n", 1) + assert 'subject="${PACKAGES_HEAD_SHA}"' in workflow_run + assert 'subject="${GITHUB_SHA}"' not in run + # The subject must be on main's history under the tip that is built. + assert 'git merge-base --is-ancestor "${subject}" "${GITHUB_SHA}"' in workflow_run + assert "head_branch" not in run + + def test_dispatch_subject_follows_first_parent_history() -> None: lines = [ line.strip() @@ -21,3 +106,103 @@ def test_dispatch_subject_follows_first_parent_history() -> None: assert lines, "the release gate no longer chooses a subject commit with git log" for line in lines: assert "--first-parent" in line, line + + +def test_a_commit_that_does_not_change_desktop_is_quietly_no_release() -> None: + steps = list(_gate_steps()) + # Classify first, so an unrelated commit never reaches the release state + # check (which refuses a partly published version loudly). + assert steps.index(SUBJECT_STEP) < steps.index(STATE_STEP) + run = _gate_steps()[SUBJECT_STEP]["run"] + assert 'git diff --quiet "${subject}^1" "${subject}" -- .' in run + assert 'git diff --quiet "${subject}" "${GITHUB_SHA}" -- .' in run + assert run.count('echo "candidate=false" >> "${GITHUB_OUTPUT}"') == 2 + assert "No release:" in run + assert ( + _gate_steps()[STATE_STEP]["if"] == "steps.subject.outputs.candidate == 'true'" + ) + for name in (ORDER_STEP, PACKAGES_STEP, FRESH_STEP): + assert _gate_steps()[name]["if"] == "steps.state.outputs.release == 'true'", ( + name + ) + + +def test_an_already_released_version_is_quietly_no_release() -> None: + run = _gate_steps()[STATE_STEP]["run"] + assert 'echo "release=false" >> "${GITHUB_OUTPUT}"' in run + # The gate's release output comes from the last check, which runs only + # after the state step said release=true. + assert _gate()["outputs"]["release"] == "${{ steps.fresh.outputs.release }}" + assert _gate_steps()[FRESH_STEP]["id"] == "fresh" + + +def test_a_partly_published_version_refuses_a_rebuild() -> None: + run = _gate_steps()[STATE_STEP]["run"] + assert "is partly published" in run + assert "do not rebuild" in run + + +def test_version_changelog_and_tag_order_still_gate_the_release() -> None: + run = _gate_steps()[ORDER_STEP]["run"] + for check in ( + "check_release_consistency.py", + "validate-tag", + "CHANGELOG.md", + "validate-release-order", + ): + assert check in run, check + + +def test_workflow_run_reads_back_the_packages_run_that_started_it() -> None: + run = _gate_steps()[PACKAGES_STEP]["run"] + workflow_run = run.split('if [ "${EVENT_NAME}" = workflow_run ]; then', 1)[1] + workflow_run = workflow_run.split("\nfi\n", 1)[0] + assert 'actions/runs/${PACKAGES_RUN_ID}"' in workflow_run + for binding in ( + '"${workflow_id}" != "${packages_workflow}"', + '"${head_sha}" != "${SUBJECT}"', + '"${event}" != push', + '"${branch}" != main', + '"${repository}" != "${GITHUB_REPOSITORY}"', + '"${status}" != completed', + '"${conclusion}" != success', + ): + assert binding in workflow_run, binding + assert 'require_desktop_jobs "${PACKAGES_RUN_ID}"' in workflow_run + # Nothing waits on a workflow_run: Packages has already finished. + assert "sleep" not in workflow_run + + +def test_the_desktop_test_and_build_jobs_must_have_passed() -> None: + run = _gate_steps()[PACKAGES_STEP]["run"] + assert 'for prefix in "desktop test / " "desktop build / "; do' in run + assert "^(failure|cancelled|timed_out)\\tdesktop (test|build) / " in run + assert run.count("require_desktop_jobs ") == 2 + + +def test_a_dispatch_waits_longer_while_packages_is_queued() -> None: + run = _gate_steps()[PACKAGES_STEP]["run"] + assert "queued_deadline=$((SECONDS + 4 * 3600))" in run + assert 'running_deadline="${running_deadline:-$((SECONDS + 3600))}"' in run + # The job outlives both bounds. + assert _gate()["timeout-minutes"] > 4 * 60 + 60 + + +def test_a_newer_desktop_change_on_main_stops_the_older_release() -> None: + run = _gate_steps()[FRESH_STEP]["run"] + assert "git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main" in run + assert 'for later in refs/remotes/origin/main "${GITHUB_SHA}"; do' in run + # Quiet for workflow_run, whose newer commit releases itself; loud for a + # dispatch, which a person is watching. + quiet, loud = run.split('echo "release=false" >> "${GITHUB_OUTPUT}"', 1) + assert 'if [ "${EVENT_NAME}" = workflow_run ]; then' in quiet + assert "exit 1" in loud + assert run.rstrip().endswith('echo "release=true" >> "${GITHUB_OUTPUT}"') + + +def test_tagging_still_requires_the_release_app_identity() -> None: + stage = { + str(step.get("name") or step.get("uses")): step + for step in _workflow()["jobs"]["stage"]["steps"] + } + assert "openadapt-release" in stage["Require the exact release App identity"]["run"]