From 1a4f8d4cbd896e29d29452a5a63511a65ae64c79 Mon Sep 17 00:00:00 2001 From: David Eiken <84112+macx@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:49 +0200 Subject: [PATCH 1/2] ci: run CI once per commit and start release only when VERSION changes Every merge to main started two workflows: ci (full run) and release (plan job only). A release commit even ran the full CI twice, once from ci.yml and once inside release.yml. release.yml now starts only for pushes that change VERSION (every release commit does), and ci.yml skips its push run for "chore(release): vX.Y.Z" commits, whose CI runs inside release.yml (github.workflow is "release" there). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 ++++++++++++-------- .github/workflows/release.yml | 9 ++++++--- docs/RELEASING.md | 4 +++- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b82d2a2..fdb37a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ # # Repository guard: the jobs run only in macx/pickup. In any other repository (the private workshop pickup-dev, # forks) they are skipped unless the repository variable PICKUP_CI_ENABLED is "true". +# +# No duplicate runs: the push of a release commit ("chore(release): vX.Y.Z") skips every job here, because +# release.yml runs this workflow for exactly that commit before it releases. Inside that call github.workflow is +# "release", so the condition only matches the push run of this workflow. name: ci on: @@ -43,7 +47,7 @@ env: jobs: release-tooling: name: release tooling (pnpm, release-it) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -61,7 +65,7 @@ jobs: lint: name: lint (swiftlint) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 15 steps: @@ -75,7 +79,7 @@ jobs: localization: name: localization check - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 20 steps: @@ -89,7 +93,7 @@ jobs: test: name: build and test (unit, scenario, fuzz) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 45 steps: @@ -117,7 +121,7 @@ jobs: sanitizers: name: sanitizers (address, thread) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 60 steps: @@ -134,7 +138,7 @@ jobs: security: name: security (gitleaks, semgrep, security-checks) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 20 steps: @@ -149,7 +153,7 @@ jobs: build: name: build release app (unsigned dry run) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" needs: [lint, localization, test, sanitizers, security] runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 45 @@ -176,7 +180,7 @@ jobs: ui-tests: name: UI tests (experimental, opt-in) - if: vars.PICKUP_CI_UI_TESTS == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + if: "vars.PICKUP_CI_UI_TESTS == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" needs: [test] runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adfc5f3..316c1dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ # Release workflow for Pickup (docs/RELEASING.md). # -# Trigger: a push to main. Trunk-based: every pull request is squash-merged into main; a release happens when the -# pushed commit is the squash-merged release pull request from prepare-release.yml ("chore(release): vX.Y.Z") or -# changes VERSION, and the tag vX.Y.Z does not exist yet. Every other push ends after the plan job. +# Trigger: a push to main that changes VERSION. Trunk-based: every pull request is squash-merged into main; a release +# happens when the pushed commit is the squash-merged release pull request from prepare-release.yml +# ("chore(release): vX.Y.Z") or changes VERSION, and the tag vX.Y.Z does not exist yet. Pushes that do not change +# VERSION do not start the workflow; a VERSION change whose tag already exists ends after the plan job. # plan decide: release or not; version; signed (PICKUP_SIGN_IDENTITY set) or unsigned; pre-release (0.x) # ci every CI job (reusable ci.yml) on exactly this commit # release environment "release" (required reviewer): build, then @@ -30,6 +31,8 @@ name: release on: push: branches: [main] + # Every release commit changes VERSION (release-it bumps it), so other pushes do not even start this workflow. + paths: [VERSION] workflow_dispatch: # re-run on main after a failure; does nothing when the version is already released permissions: {} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e261125..e322977 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -196,7 +196,9 @@ pnpm install --frozen-lockfile && pnpm release:dry-run # on a release/* ``` `PICKUP_SKIP_VERIFY=1` is accepted for release builds only when they are ad-hoc signed. CI builds the same unsigned -DMG and ZIP on every push to main and every pull request (artifact `pickup-unsigned-`, kept 7 days). +DMG and ZIP on every pull request and every push to main (artifact `pickup-unsigned-`, kept 7 days). The push of +a release commit skips ci.yml, because release.yml runs the same CI for that commit; release.yml itself starts only +when a push changes `VERSION`. So every commit gets exactly one CI run. ## Verification From 22d71e134596b0311f02f0c05a4439f8ef880b47 Mon Sep 17 00:00:00 2001 From: David Eiken <84112+macx@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:19:03 +0200 Subject: [PATCH 2/2] ci: one release decision for ci.yml and release.yml Review: the release trigger (paths: VERSION) and the CI skip (commit subject) selected different commits, so a release commit without a VERSION change, a fork with CI enabled, or a VERSION change without the release prefix ended up with no CI or with two runs. scripts/release-decision.sh now decides for both: a push in macx/pickup that changes VERSION and whose tag does not exist yet (or a manual run of release.yml). A route job in ci.yml applies it to all eight jobs; release.yml's plan job uses it too. The same-version first release is history (v0.1.0 exists). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++-------- .github/workflows/release.yml | 27 +++++++---------- docs/RELEASING.md | 15 ++++++---- scripts/release-decision.sh | 37 +++++++++++++++++++++++ 4 files changed, 100 insertions(+), 35 deletions(-) create mode 100755 scripts/release-decision.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdb37a9..2967979 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ # Repository guard: the jobs run only in macx/pickup. In any other repository (the private workshop pickup-dev, # forks) they are skipped unless the repository variable PICKUP_CI_ENABLED is "true". # -# No duplicate runs: the push of a release commit ("chore(release): vX.Y.Z") skips every job here, because -# release.yml runs this workflow for exactly that commit before it releases. Inside that call github.workflow is -# "release", so the condition only matches the push run of this workflow. +# One CI run per commit: the route job skips this workflow's own push run for a commit that release.yml releases, +# because release.yml runs this workflow (workflow_call) for exactly that commit. Both decide with +# scripts/release-decision.sh. Inside that call github.workflow is "release", so the route job lets every job run. name: ci on: @@ -45,9 +45,34 @@ env: HOMEBREW_NO_INSTALL_CLEANUP: "1" jobs: + route: + name: route (skip the push run of a release commit) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.route.outputs.run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.workflow == 'ci' && github.event_name == 'push' + with: + persist-credentials: false + fetch-depth: 0 # tags and the parent commit for scripts/release-decision.sh + - id: route + run: | + set -euo pipefail + run=true + if [ "$GITHUB_WORKFLOW" = ci ] && [ "$GITHUB_EVENT_NAME" = push ]; then + if scripts/release-decision.sh | grep -qx 'release=true'; then + run=false + echo "::notice::release commit: release.yml runs CI for it; skipping this push run" + fi + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + release-tooling: name: release tooling (pnpm, release-it) - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: route runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -65,7 +90,8 @@ jobs: lint: name: lint (swiftlint) - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: route runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 15 steps: @@ -79,7 +105,8 @@ jobs: localization: name: localization check - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: route runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 20 steps: @@ -93,7 +120,8 @@ jobs: test: name: build and test (unit, scenario, fuzz) - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: route runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 45 steps: @@ -121,7 +149,8 @@ jobs: sanitizers: name: sanitizers (address, thread) - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: route runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 60 steps: @@ -138,7 +167,8 @@ jobs: security: name: security (gitleaks, semgrep, security-checks) - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: route runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 20 steps: @@ -153,8 +183,8 @@ jobs: build: name: build release app (unsigned dry run) - if: "(github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" - needs: [lint, localization, test, sanitizers, security] + if: needs.route.outputs.run == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: [route, lint, localization, test, sanitizers, security] runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 45 env: @@ -180,8 +210,8 @@ jobs: ui-tests: name: UI tests (experimental, opt-in) - if: "vars.PICKUP_CI_UI_TESTS == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') && !(github.workflow == 'ci' && github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(release): v'))" - needs: [test] + if: needs.route.outputs.run == 'true' && vars.PICKUP_CI_UI_TESTS == 'true' && (github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true') + needs: [route, test] runs-on: ${{ vars.PICKUP_MACOS_RUNNER || 'xcode-27' }} timeout-minutes: 30 continue-on-error: true # not a required check: see the header of this file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 316c1dc..cad6ab6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ # Release workflow for Pickup (docs/RELEASING.md). # -# Trigger: a push to main that changes VERSION. Trunk-based: every pull request is squash-merged into main; a release -# happens when the pushed commit is the squash-merged release pull request from prepare-release.yml -# ("chore(release): vX.Y.Z") or changes VERSION, and the tag vX.Y.Z does not exist yet. Pushes that do not change -# VERSION do not start the workflow; a VERSION change whose tag already exists ends after the plan job. +# Trigger: a push to main that changes VERSION. Trunk-based: every pull request is squash-merged into main; the +# squash-merged release pull request from prepare-release.yml ("chore(release): vX.Y.Z") bumps VERSION. A release +# happens when the push changes VERSION and the tag vX.Y.Z does not exist yet (scripts/release-decision.sh, the same +# rule the push run of ci.yml uses to skip itself). A VERSION change whose tag already exists ends after the plan job. # plan decide: release or not; version; signed (PICKUP_SIGN_IDENTITY set) or unsigned; pre-release (0.x) # ci every CI job (reusable ci.yml) on exactly this commit # release environment "release" (required reviewer): build, then @@ -31,7 +31,8 @@ name: release on: push: branches: [main] - # Every release commit changes VERSION (release-it bumps it), so other pushes do not even start this workflow. + # Only a push that changes VERSION can be released (scripts/release-decision.sh; every release commit from + # prepare-release bumps VERSION), so other pushes do not start this workflow. paths: [VERSION] workflow_dispatch: # re-run on main after a failure; does nothing when the version is already released @@ -66,18 +67,12 @@ jobs: set -euo pipefail [ "$GITHUB_REF" = refs/heads/main ] || { echo "::error::release.yml runs on main only (got $GITHUB_REF)"; exit 1; } scripts/set-version.sh --check - version="$(scripts/set-version.sh --print)" subject="$(git log -1 --format=%s)" - parent_version="$(git show HEAD^:VERSION 2>/dev/null | tr -d '[:space:]' || true)" - release=false - if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then - echo "::notice::v$version is already released; nothing to do." - elif [ "$(sed -E 's/ \(#[0-9]+\)$//' <<< "$subject")" = "chore(release): v$version" ] || [ "$parent_version" != "$version" ] \ - || [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then - release=true - else - echo "::notice::not a release commit and VERSION unchanged ($version); nothing to do." - fi + # The same rule as the push run of ci.yml (route job), so that run is skipped exactly when this one runs CI. + decision="$(scripts/release-decision.sh)" + release="$(sed -n 's/^release=//p' <<< "$decision")" + version="$(sed -n 's/^version=//p' <<< "$decision")" + [ "$release" = true ] || echo "::notice::not a release (see release-decision above); nothing to do." signed=false; [ -n "$SIGN_IDENTITY" ] && signed=true prerelease=false; [ "${version%%.*}" = 0 ] && prerelease=true if [ "$release" = true ] && [ "$signed" = false ] && [ "$prerelease" = false ]; then diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e322977..d76bf16 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -47,15 +47,15 @@ main ── PRs (squash) ──●──●──●───────── package.json, CHANGELOG.md, one commit, no tag); CI runs on the PR release.yml: plan ─> ci (all jobs, this commit) ─> release [environment "release", approval] ─> homebrew (signed only) - plan: release only if the commit is "chore(release): vX.Y.Z" or changes VERSION, and vX.Y.Z is not tagged + plan: release only if the push changes VERSION and vX.Y.Z is not tagged (scripts/release-decision.sh) release: build; signed: sign + notarise + staple; unsigned (0.x only): ad-hoc, *-UNSIGNED files; GitHub Release (notes = CHANGELOG section; pre-release while 0.x); publishing creates tag vX.Y.Z ``` 1. **R1 (David)** Actions > `prepare-release` > Run workflow on `main`. Version: empty (computed from the Conventional Commits since the last tag) or `patch` / `minor` / `major` / `X.Y.Z`. - **The first release is `0.1.0`:** with no tag yet, release-it would start from 0.0.0, so enter `0.1.0` explicitly - (the workflow refuses a computed version lower than `VERSION`). + The release commit must bump `VERSION`: only a push that changes `VERSION` is released. (The first release, + `0.1.0`, was entered explicitly because release-it would have started from 0.0.0 without a tag.) 2. The workflow cuts `release/vX.Y.Z` from `main`, runs release-it there (`pnpm exec release-it [version] --ci`), pushes the branch and opens the pull request `chore(release): vX.Y.Z` into `main` with the changelog section and a checklist. Running it again replaces the branch and updates the pull request. @@ -196,9 +196,12 @@ pnpm install --frozen-lockfile && pnpm release:dry-run # on a release/* ``` `PICKUP_SKIP_VERIFY=1` is accepted for release builds only when they are ad-hoc signed. CI builds the same unsigned -DMG and ZIP on every pull request and every push to main (artifact `pickup-unsigned-`, kept 7 days). The push of -a release commit skips ci.yml, because release.yml runs the same CI for that commit; release.yml itself starts only -when a push changes `VERSION`. So every commit gets exactly one CI run. +DMG and ZIP on every pull request and every push to main (artifact `pickup-unsigned-`, kept 7 days). + +**One CI run per commit.** `release.yml` starts only for pushes that change `VERSION`. `scripts/release-decision.sh` +decides for both workflows whether a commit is released (official repository, `VERSION` changed against the parent, +tag missing; or a manual run of `release.yml`). For such a commit the `route` job of `ci.yml` skips the push run, +because `release.yml` runs the same CI for it; for every other commit the push run of `ci.yml` is the only one. ## Verification diff --git a/scripts/release-decision.sh b/scripts/release-decision.sh new file mode 100755 index 0000000..ac25bb1 --- /dev/null +++ b/scripts/release-decision.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Decides whether the checked-out commit is released. ci.yml and release.yml both use this one rule, so the push run +# of ci.yml is skipped exactly when release.yml runs the same CI for the commit (docs/RELEASING.md). +# +# scripts/release-decision.sh prints "release=true|false" and "version=X.Y.Z" (GITHUB_OUTPUT format), a reason +# on stderr +# +# A commit is released when all of these hold: +# - the repository is macx/pickup (official builds only; forks never release), +# - the tag vX.Y.Z of VERSION does not exist yet, +# - it is a push that changes VERSION against its parent (every release commit from prepare-release does), or a +# manual run (workflow_dispatch) of release.yml. +# Needs the parent commit and the tags (actions/checkout with fetch-depth: 0). +# Inputs: GITHUB_REPOSITORY, GITHUB_EVENT_NAME (set by GitHub Actions). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +version="$(scripts/set-version.sh --print)" +parent_version="$(git show HEAD^:VERSION 2>/dev/null | tr -d '[:space:]' || true)" +release=false +if [ "${GITHUB_REPOSITORY:-}" != macx/pickup ]; then + reason="not the official repository (${GITHUB_REPOSITORY:-unset})" +elif git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then + reason="v$version is already released" +elif [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then + release=true; reason="manual run, v$version not tagged yet" +elif [ "${GITHUB_EVENT_NAME:-}" != push ]; then + reason="event ${GITHUB_EVENT_NAME:-unset} is neither a push nor a manual run" +elif [ "$parent_version" != "$version" ]; then + release=true; reason="VERSION changed ${parent_version:-none} -> $version" +else + reason="VERSION unchanged ($version)" +fi +echo "release=$release" +echo "version=$version" +echo "release-decision: release=$release: $reason" >&2