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
54 changes: 44 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
26 changes: 12 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]
Comment thread
macx marked this conversation as resolved.
workflow_dispatch: # re-run on main after a failure; does nothing when the version is already released

permissions: {}
Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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-<sha>`, kept 7 days).
DMG and ZIP on every pull request and every push to main (artifact `pickup-unsigned-<sha>`, 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

Expand Down
37 changes: 37 additions & 0 deletions scripts/release-decision.sh
Original file line number Diff line number Diff line change
@@ -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
Loading