# alt-tab-free — upstream sync workflow (docs/PLAN-maintained-fork.md §4.2) # # Polls lwouis/alt-tab-macos for new release tags and opens a reviewable sync PR. # Selects the OLDEST upstream tag strictly newer than the last-merged tag recorded in # `.fork-sync-state` (NOT "newest tag minus --is-ancestor"). This is the §4.2 # never-drop-commits invariant: `.fork-sync-state` advances ONLY when a sync PR merges to # master (a ci_cd.yml push-job step — owned by the CI agent), so a sync PR that is # closed-without-merge or abandoned can never silently skip its tag — that tag stays "next" # until it is actually merged. This subsumes the old `merge-base --is-ancestor` guard. # # A release is cut only when a human merges the PR to master (ci_cd.yml on: push). This # workflow never releases; it only opens the PR for review. name: upstream-sync on: # §4.2 cron-durability note: the in-repo `schedule:` SILENTLY auto-disables after 60 days # of repo inactivity (no error, no notification). It is kept here as belt-and-suspenders. # The RECOMMENDED PRIMARY trigger is an EXTERNAL scheduler hitting `repository_dispatch` # carrying the SYNC_BOT_TOKEN (immune to the 60-day rule, and composes with the # workflow-re-trigger token). See ownerActions / §4.2. schedule: - cron: '0 7 * * *' # daily; cheap, idempotent workflow_dispatch: {} repository_dispatch: types: [upstream-sync] # external-scheduler entry point (§4.2 primary durability path) # GITHUB_TOKEN is read-only by default; opening a PR + pushing a branch need these. permissions: contents: write pull-requests: write jobs: sync: runs-on: ubuntu-latest # pure git, no Xcode env: # CRITICAL (§4.2 BLOCKING) — do NOT use ${{ github.token }} here. By GitHub's documented # recursion-prevention rule, a branch push / PR authored with the default GITHUB_TOKEN # does NOT trigger other workflows — so guard.yml and ci_cd.yml's pull_request: checks # would NEVER fire on the sync PR and the entire pre-merge gate would silently never run. # SYNC_BOT_TOKEN MUST be a fork-owned credential that re-triggers workflows: a GitHub App # installation token (preferred — least-privilege, short-lived) or a fork-owned PAT. GH_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # Persist the fork-owned credential (NOT the default GITHUB_TOKEN) as the push # credential, so the branch push below re-triggers guard.yml / ci_cd.yml PR checks. token: ${{ secrets.SYNC_BOT_TOKEN }} - name: Fail closed if SYNC_BOT_TOKEN is missing run: | # §4.2: without the workflow-re-triggering token the pre-merge gate is silently # disarmed. Fail loudly here rather than open a check-less PR. if [ -z "${SYNC_BOT_TOKEN:-}" ]; then echo "::error::SYNC_BOT_TOKEN is not set. Refusing to open a check-less sync PR (§4.2)." echo "Provision a GitHub App installation token (preferred) or fork-owned PAT as SYNC_BOT_TOKEN." exit 1 fi env: SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} - name: Add upstream + fetch tags run: | set -euo pipefail git remote add upstream https://github.com/lwouis/alt-tab-macos.git || true git fetch upstream --tags --force - name: Verify sync labels exist (§4.2 — fail closed, never treat empty as "no open sync") run: | set -euo pipefail # Guard 2 below and the §0.1 trip-wire query `sync` / `conflict` / `chokepoint-refresh`. # `gh pr list --label ` returns an EMPTY LIST, not an error, for a label that # was never created — so on a fresh repo Guard 2 would silently no-op. Fail closed if a # required label is absent; the bootstrap (§6.1) creates them with `gh label create`. missing=0 for L in sync conflict chokepoint-refresh; do if ! gh label list --json name --jq '.[].name' | grep -qxF "$L"; then echo "::error::Required label '$L' is missing. Create it at bootstrap (§6.1): gh label create $L" missing=1 fi done [ "$missing" -eq 0 ] || exit 1 - name: Select oldest upstream tag strictly newer than .fork-sync-state id: tag run: | set -euo pipefail # State-file-driven selection (§4.2): NOT "newest tag minus --is-ancestor". last_synced="$(tr -d '[:space:]' < .fork-sync-state)" if [ -z "$last_synced" ]; then echo "::error::.fork-sync-state is empty; seed it with the last-merged upstream tag (e.g. v11.3.0)." exit 1 fi echo "Last synced (from .fork-sync-state): $last_synced" # UPSTREAM-ONLY enumeration: `git tag -l 'v*'` would also match the FORK's OWN # semantic-release tags (the fork cuts vX.Y.Z every sync — §4.3), so list tags from # the upstream remote explicitly. Sort by version; keep only tags strictly newer than # the recorded one; take the OLDEST of those (resolve-in-order — §4.2). all_newer="$(git ls-remote --tags --refs upstream 'v*' \ | awk -F/ '{print $NF}' \ | sort -V \ | awk -v last="$last_synced" ' $0 == last { seen = 1; next } seen { print } ')" # Fallback for the case where last_synced is not itself present upstream: take every # tag that sorts strictly after it. if [ -z "$all_newer" ]; then all_newer="$(git ls-remote --tags --refs upstream 'v*' \ | awk -F/ '{print $NF}' \ | sort -V \ | awk -v last="$last_synced" '{ if ($0 != last) print $0 }' \ | while read -r t; do # keep only tags that sort AFTER last_synced if [ "$(printf '%s\n%s\n' "$last" "$t" | sort -V | tail -1)" = "$t" ] && [ "$t" != "$last" ]; then echo "$t" fi done)" fi next="$(printf '%s\n' "$all_newer" | sed '/^$/d' | head -1)" if [ -z "$next" ]; then echo "Up to date: no upstream tag newer than $last_synced." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "Next tag to sync (oldest strictly newer): $next" # Guard — a prior sync is still OPEN (resolve-in-order, §4.2): do NOT open a new PR # while any sync/* PR is unmerged. A backlog pauses syncing until the human merges it; # this is the never-drop-commits behavior. The liveness alert keys off the stalled flag. # `gh pr list --label sync` fails closed because the label existence was verified above. open_sync="$(gh pr list --state open --label sync --json number,headRefName,createdAt \ --jq 'sort_by(.createdAt) | .[0] // empty')" if [ -n "$open_sync" ]; then echo "A prior sync PR is still open (resolve-in-order): $open_sync" echo "skip=true" >> "$GITHUB_OUTPUT" echo "stalled=true" >> "$GITHUB_OUTPUT" # liveness alert keys off this (§4.2) exit 0 fi echo "latest=$next" >> "$GITHUB_OUTPUT" - name: Merge onto a sync branch (real merge commit, conventional message) if: steps.tag.outputs.skip != 'true' id: merge run: | set -euo pipefail TAG='${{ steps.tag.outputs.latest }}' git config user.name "fork-sync-bot" git config user.email "fork-sync-bot@users.noreply.github.com" git switch -c "sync/${TAG}" # NO --no-commit: let git author a REAL merge commit with a CONVENTIONAL message so # commitlint / @semantic-release/commit-analyzer stay happy (§4.3). On conflict, commit # the conflicted tree (markers included, --no-verify) so the PR shows the markers for a # human to resolve; Guard C (guard.yml + ci_cd.yml) then blocks it from ever shipping. if git merge --no-ff -m "chore(sync): merge upstream ${TAG}" "${TAG}"; then conflicts=false else git add -A git commit --no-verify \ -m "chore(sync): merge upstream ${TAG} (CONFLICTS — resolve before merge)" conflicts=true fi echo "conflicts=${conflicts}" >> "$GITHUB_OUTPUT" # §0.1 trip-wire A — surface chokepoint churn as a per-sync signal. A nonzero count # means an upstream change touched a de-paywall chokepoint file in this merge range # (last-synced tag .. new tag); the PR is labeled `chokepoint-refresh` (below) so the # trip-wire is a single query, not a memory exercise. CHOKEPOINTS="src/pro/license/LicenseManager.swift src/pro/ProFeature.swift src/preferences/PreferenceDefinition.swift src/pro/scheduling/ProTransitionManager.swift src/pro/license/LicenseCookie.swift src/vendors/AppCenterCrashes.swift" LAST_SYNCED="$(tr -d '[:space:]' < .fork-sync-state)" churn="$(git rev-list --count "${LAST_SYNCED}..${TAG}" -- $CHOKEPOINTS 2>/dev/null || echo 0)" # Also flag if the merge left a conflict in any chokepoint file (the conflicted commit # is HEAD; compare against its first parent HEAD~1 = the pre-merge master tip). chokepoint_conflict=0 if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE 'LicenseManager\.swift|ProFeature\.swift|PreferenceDefinition\.swift|ProTransitionManager\.swift|LicenseCookie\.swift|AppCenterCrashes\.swift'; then if [ "${conflicts:-false}" = "true" ]; then chokepoint_conflict=1 fi fi echo "churn=${churn}" >> "$GITHUB_OUTPUT" echo "chokepoint_conflict=${chokepoint_conflict}" >> "$GITHUB_OUTPUT" git push origin "HEAD:sync/${TAG}" - name: Open PR if: steps.tag.outputs.skip != 'true' run: | set -euo pipefail TAG='${{ steps.tag.outputs.latest }}' CHURN='${{ steps.merge.outputs.churn }}' CONFLICTS='${{ steps.merge.outputs.conflicts }}' CHOKEPOINT_CONFLICT='${{ steps.merge.outputs.chokepoint_conflict }}' # Label fail-closed (§4.2): conflict -> `sync,conflict`, else `sync`. if [ "$CONFLICTS" = "true" ]; then LABELS="sync,conflict" else LABELS="sync" fi BODY="Automated upstream sync to \`${TAG}\`. Selected as the OLDEST upstream tag strictly newer than \`.fork-sync-state\` (§4.2). Verify the guard.yml + ci_cd.yml PR checks are GREEN (anti-relock test + marker/conflict greps) before merge. If checks did not appear, SYNC_BOT_TOKEN is mis-set to the default GITHUB_TOKEN — the gate is disarmed (§4.2). Chokepoint-churn count for this merge range: ${CHURN} (nonzero => de-paywall chokepoint touched, see §0.1 trip-wire A). NEVER close this PR to defer it — \`.fork-sync-state\` only advances on MERGE, so leaving it OPEN keeps this tag 'next'; closing it would silently skip the tag (§4.2 / §6.2)." gh pr create \ --head "sync/${TAG}" \ --base master \ --title "sync: merge upstream ${TAG}" \ --body "$BODY" \ --label "$LABELS" # §0.1 trip-wire A — auto-apply `chokepoint-refresh` from CI when a chokepoint file # changed or conflicted, so the trip-wire tally never depends on a human remembering. if [ "${CHURN:-0}" -gt 0 ] || [ "${CHOKEPOINT_CONFLICT:-0}" = "1" ]; then gh pr edit "sync/${TAG}" --add-label chokepoint-refresh || true fi