# 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).