227 lines
12 KiB
YAML
227 lines
12 KiB
YAML
# 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 <missing>` 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
|