Files
CommandTabFree/.github/workflows/ci_cd.yml
james.maes dd15f22031 ci: gate release apparatus on semantic-release will-release decision
determine_next_version.sh writes an empty VERSION.txt when no commit since the last tag is release-worthy; gate cert-import/sign-build/notarize/appcast/release (and version injection) on that, so non-release pushes build+test without cutting a release or needing [skip ci].
2026-06-16 16:11:32 -05:00

227 lines
16 KiB
YAML

# alt-tab-free: fork-owned CI/CD workflow (diverged from upstream lwouis/alt-tab-macos).
# Upstream's ci_cd.yml fires `on: push: branches: [master]` ONLY and is the sole workflow,
# so this fork ADDS a `pull_request:` trigger to gate the sync PR pre-merge (see PLAN §4.2),
# gates every release/push-only step under `if: github.event_name == 'push'`, and wires the
# anti-relock + conflict-marker greps as the FIRST push-job steps so a relocked or conflicted
# tree aborts BEFORE update_appcast.sh (Sparkle sign), semantic-release (tag push), and the
# gh-release. This file is in the §6.2 "keep ours" recurring-conflict set — on an upstream
# ci_cd.yml change, reconcile manually and re-confirm the `pull_request:` trigger survives.
on:
push:
branches:
- master
# alt-tab-free [depaywall]: PR trigger so the sync PR runs the guards pre-merge (PLAN §4.2).
# A separate fork-owned .github/workflows/guard.yml ALSO runs the guards on pull_request, so a
# careless upstream-conflict resolution that strips this trigger cannot silently disarm the gate.
pull_request:
branches:
- master
# alt-tab-free [depaywall]: GitHub defaults GITHUB_TOKEN to read-only since 2023; semantic-release
# (@semantic-release/git) pushes commits+tags to master and softprops/action-gh-release creates the
# release — both need contents:write, else they 403 and the appcast enclosure 404s for auto-update
# (PLAN §4.3).
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPCENTER_SECRET: ${{ secrets.APPCENTER_SECRET }}
APPCENTER_TOKEN: ${{ secrets.APPCENTER_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_P12_CERTIFICATE: ${{ secrets.APPLE_P12_CERTIFICATE }}
# alt-tab-free [depaywall]: optional .p12 export password (empty if exported password-less).
# Not part of the release-gate AND — the cert import tolerates an empty password.
APPLE_P12_PASSWORD: ${{ secrets.APPLE_P12_PASSWORD }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
GITHUB_EVENT_AFTER: ${{ github.event.after }}
BUILD_DIR: ${{ github.workspace }}
XCODE_BUILD_PATH: DerivedData/Build/Products/Release
VERSION_FILE: VERSION.txt
# alt-tab-free [depaywall]: must equal PRODUCT_NAME in config/local.xcconfig
# (PLAN §3.1/§4.3) or the build/package/appcast scripts mismatch the artifact.
APP_NAME: CommandTabFree
jobs:
build:
runs-on: macos-15 # all options: https://github.com/actions/runner-images/tree/main
environment: production
steps:
# - env:
# GITHUB_CONTEXT: ${{ toJSON(github) }}
# run: echo "$GITHUB_CONTEXT"
- id: pre-checkout
env:
COMMITS: ${{ toJson(github.event.commits) }}
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
COUNT=$(( $(echo "$COMMITS" | jq length) + 30 ))
else
COUNT=1
fi
echo "fetch-depth=$COUNT" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v6
with:
fetch-depth: ${{ steps.pre-checkout.outputs.fetch-depth }}
# alt-tab-free [depaywall]: Guard B + Guard C run FIRST on the push job (before run_tests.sh,
# and therefore before update_appcast.sh's Sparkle sign + semantic-release's tag push) so a
# relock or a still-conflicted tree aborts the job BEFORE any irreversible side-effect (PLAN
# §4.4 "Where the guards run"). They are near-instant target-independent greps, so running them
# first is free. They also run on the PR path for the fastest red signal. A second copy sits
# just before action-gh-release as belt-and-suspenders.
- name: anti-relock — verify de-paywall marker present (Guard B)
run: |
grep -q 'depaywall' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
# The marker proves the patch is present; pair with the if-false wrap the patch introduces.
# A bare `return .pro` grep is NOT load-bearing (it matches the unpatched line 187 too).
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: 'guard false else' (forced .pro) missing from computeState()"; exit 1; }
- name: anti-conflict — no unresolved merge markers (Guard C)
run: |
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "UNRESOLVED CONFLICT MARKERS present — resolve before merge/release"; exit 1
fi
# alt-tab-free [depaywall]: release-readiness gate (PLAN §3.3/§4.3 owner-secret checklist).
# The fork ships unsigned/ad-hoc until the OWNER provisions the Apple Developer ID cert and the
# Sparkle key. We do NOT hard-fail when those secrets are absent — that would make every push
# RED on a fork that is correctly built but not yet signing-provisioned. Instead we compute a
# boolean from secret presence and SKIP the sign/notarize/appcast/release tail cleanly when it
# is false. The build + Guard A (run_tests.sh) still run on push, so push CI is GREEN today and
# the release tail FLIPS ON automatically the moment the owner adds the secrets. Secrets are not
# available in step-level `if:` directly, so map them to env and emit an output here.
- name: release-gate — are signing/notarization secrets present?
id: release-gate
env:
HAS_P12: ${{ secrets.APPLE_P12_CERTIFICATE != '' }}
HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }}
HAS_APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD != '' }}
HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }}
HAS_SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY != '' }}
run: |
set -euo pipefail
if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_ID" = "true" ] \
&& [ "$HAS_APPLE_PASSWORD" = "true" ] && [ "$HAS_APPLE_TEAM_ID" = "true" ] \
&& [ "$HAS_SPARKLE_KEY" = "true" ]; then
echo "signing-ready=true" >> "$GITHUB_OUTPUT"
echo "All signing/notarization/Sparkle secrets present — release tail WILL run on push."
else
echo "signing-ready=false" >> "$GITHUB_OUTPUT"
echo "::warning::Signing/notarization/Sparkle secrets absent — release tail (codesign/notarize/appcast/semantic-release/gh-release) is SKIPPED. Build + Guard A still run. Provision APPLE_P12_CERTIFICATE / APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID / SPARKLE_ED_PRIVATE_KEY in the 'production' Environment to enable releases (PLAN §3.3/§3.4)."
fi
- uses: actions/setup-node@v6
with:
node-version: 16 # PLAN §4.3: keep node-16; the package.json engines mismatch only warns
cache: 'npm'
- run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer
- run: scripts/print_env.sh
- run: npm ci
# alt-tab-free [depaywall]: commitlint is gated to the push path. On push it lints the whole
# pushed range, which drags in upstream's non-conforming messages on a `chore(sync)` merge —
# this is handled by an `ignores` predicate in commitlint.config.js (PLAN §4.3) that drops the
# synthetic merge commit and upstream's own messages; do NOT break commitlint on --no-ff merges.
# $GITHUB_EVENT_BEFORE/AFTER are push-context (empty on pull_request), so a PR-safe variant runs
# below on the PR path instead.
- run: npx commitlint --from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER" --verbose
if: github.event_name == 'push'
# alt-tab-free [depaywall]: PR-safe commitlint — lints base..head (valid on pull_request),
# paired with the commitlint.config.js `ignores` predicate (PLAN §4.2/§4.3).
- run: npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" --verbose
if: github.event_name == 'pull_request'
# Runs on both push and PR: catches an un-regenerated l10n/generated-file diff (PLAN §4.2/§4.5).
- run: scripts/ensure_generated_files_are_up_to_date.sh
# alt-tab-free [depaywall]: release-path — a semantic-release dry-run against a PR head is
# meaningless and can mis-bump, so gate to push (PLAN §4.2).
- run: scripts/determine_next_version.sh
if: github.event_name == 'push'
# alt-tab-free [depaywall]: decouple the release apparatus from "every signing-ready push".
# determine_next_version.sh writes an EMPTY VERSION.txt when no commit since the last tag is
# release-worthy (semantic-release dry-run finds no bump). Gate the sign/notarize/release steps
# on this so a docs/ci/chore push still runs guards + commitlint + run_tests (Release-config
# compile check) but does NOT sign-build, notarize, or cut a release — no [skip ci] needed (§4.3).
- name: release-decision — did semantic-release compute a version?
id: release-decision
if: github.event_name == 'push'
run: |
if [ -n "$(tr -d '[:space:]' < "$VERSION_FILE" 2>/dev/null)" ]; then
echo "will-release=true" >> "$GITHUB_OUTPUT"
echo "Release-worthy: semantic-release will cut v$(cat "$VERSION_FILE")."
else
echo "will-release=false" >> "$GITHUB_OUTPUT"
echo "::notice::No release-worthy commit — build/test only; notarize+release tail skipped."
fi
# Inject the release version only when releasing; an empty CURRENT_PROJECT_VERSION would crash
# App.version (see config/local.xcconfig). Non-release builds use the committed 0.0.0 default.
- run: scripts/replace_environment_variables_in_app.sh
if: github.event_name == 'push' && steps.release-decision.outputs.will-release == 'true'
# codesign needs APPLE_P12_CERTIFICATE — gated on signing-ready so a not-yet-provisioned fork
# does not RED on the missing-cert import (PLAN §3.3/§4.3).
- run: scripts/codesign/setup_ci_master.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# Guard A — the anti-relock unit test (appended to LicenseManagerTests.swift). Runs on both
# push and PR (no secrets needed); on the push path it sits before the sign/tag steps below
# so a relock fails BEFORE any irreversible side-effect (PLAN §4.4). This is what keeps push
# CI GREEN today on the signing-unprovisioned fork.
- run: scripts/run_tests.sh
# alt-tab-free [depaywall]: build the Release product to prove the tree compiles. The fork
# inherits upstream's Developer ID via config/release.xcconfig (CODE_SIGN_IDENTITY), which is
# absent on CI until the owner provisions the Apple cert — so when the release gate is NOT
# signing-ready we build AD-HOC (CODE_SIGN_IDENTITY=-, signing not required, no timestamp) to
# prove the tree compiles without a cert. When the owner adds the secrets AND sets the fork's
# Developer ID in config/local.xcconfig, this builds signed for the release tail (PLAN §3.3/§4.3).
- name: build app (signed — owner cert provisioned)
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
run: scripts/build_app.sh
- name: build app (ad-hoc — until signing-provisioned)
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready != 'true'
run: scripts/build_app.sh CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO OTHER_CODE_SIGN_FLAGS=--timestamp=none
# --- Release tail: requires the owner's Apple Developer ID + Sparkle secrets. Skipped cleanly
# (NOT failed) when they are absent, so push CI stays GREEN until the owner provisions them. ---
- run: scripts/package_and_notarize_release.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# alt-tab-free [depaywall]: upload_symbols_to_appcenter.sh REMOVED — telemetry is off (PLAN §3.4).
- run: scripts/update_appcast.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# alt-tab-free [depaywall]: update_readme_and_website.sh REMOVED — it queried the UPSTREAM
# lwouis/alt-tab-macos GitHub API for stars/downloads and rewrote docs/contributors.md from the
# UPSTREAM contributor graph (semantic mismatch for the fork) and dispatched the lwouis website.
# Cosmetic only; dropped per PLAN §4.0/§4.3 (alongside update_website.sh, already removed).
- run: npx semantic-release
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
- id: set_release_info
run: scripts/extract_latest_changelog.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# alt-tab-free [depaywall]: belt-and-suspenders copy of Guard B + Guard C immediately before the
# release is created. The load-bearing copy is at the top of the job (pre-tag-push); this second
# copy guards the gh-release object itself (PLAN §4.4).
- name: anti-relock + anti-conflict re-check (belt-and-suspenders)
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
run: |
grep -q 'depaywall' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: 'guard false else' (forced .pro) missing from computeState()"; exit 1; }
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "UNRESOLVED CONFLICT MARKERS present — resolve before release"; exit 1
fi
# alt-tab-free [depaywall]: point the release at THIS fork's repo via the ambient GITHUB_TOKEN
# (release.config.js has no @semantic-release/github plugin, so softprops is the sole release
# creator — PLAN §4.3). repository defaults to ${{ github.repository }} = the fork.
- uses: softprops/action-gh-release@v3
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
with:
repository: ${{ github.repository }}
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.set_release_info.outputs.tag_name }}
body: ${{ steps.set_release_info.outputs.body }}
files: ${{ env.XCODE_BUILD_PATH }}/*.zip
# alt-tab-free [depaywall]: the release tail intentionally does NOT advance .fork-sync-state.
# That file is the UPSTREAM-sync cursor — the last-synced UPSTREAM tag (e.g. v11.3.0), read by
# upstream_sync.yml to pick "the oldest upstream tag strictly newer than it." The fork versions
# independently (semantic-release cuts v100.x), so a prior revision's step that wrote the FORK
# release tag here set the cursor to v100.x — after which no upstream tag is ever "newer" and
# upstream-sync would silently never fire. The cursor must instead be advanced by the
# upstream-sync merge flow, recording the UPSTREAM tag it synced (to be wired with
# upstream_sync.yml — PLAN §4.2). Until then .fork-sync-state stays at the last real upstream tag.
# alt-tab-free [depaywall]: update_website.sh (lwouis website dispatch) REMOVED (PLAN §3.4/§4.2).