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].
227 lines
16 KiB
YAML
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).
|