diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b82d2a2..2967979 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". +# +# 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: @@ -41,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' + 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: @@ -61,7 +90,8 @@ jobs: lint: name: lint (swiftlint) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + 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: @@ -75,7 +105,8 @@ jobs: localization: name: localization check - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + 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: @@ -89,7 +120,8 @@ jobs: test: name: build and test (unit, scenario, fuzz) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + 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: @@ -117,7 +149,8 @@ jobs: sanitizers: name: sanitizers (address, thread) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + 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: @@ -134,7 +167,8 @@ jobs: security: name: security (gitleaks, semgrep, security-checks) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' + 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: @@ -149,8 +183,8 @@ jobs: build: name: build release app (unsigned dry run) - if: github.repository == 'macx/pickup' || vars.PICKUP_CI_ENABLED == 'true' - 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: @@ -176,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') - 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 adfc5f3..cad6ab6 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; 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 @@ -30,6 +31,9 @@ name: release on: push: branches: [main] + # 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 permissions: {} @@ -63,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 e261125..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,7 +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 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). + +**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