Skip to content

Sync Upstream

Sync Upstream #1520

Workflow file for this run

name: Sync Upstream
on:
schedule:
- cron: '0 */2 * * *' # Every 2 hours
workflow_dispatch: {} # Manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout with full history
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Add upstream remote and fetch
run: |
git remote add upstream https://github.com/TanStack/pacer.git
git fetch upstream main
- name: Compare versions across all packages
id: compare
run: |
PACKAGES=(
angular-pacer
pacer
pacer-devtools
pacer-lite
preact-pacer
preact-pacer-devtools
react-pacer
react-pacer-devtools
solid-pacer
solid-pacer-devtools
)
NEEDS_SYNC=false
CHANGED=""
for PKG in "${PACKAGES[@]}"; do
UPSTREAM=$(git show upstream/main:packages/$PKG/package.json | jq -r '.version')
OURS=$(git show origin/main:packages/$PKG/package.json | jq -r '.version')
if [ "$UPSTREAM" != "$OURS" ]; then
echo "$PKG: upstream v$UPSTREAM vs ours v$OURS"
NEEDS_SYNC=true
CHANGED="${CHANGED}- **$PKG**: v$OURS → v$UPSTREAM\n"
else
echo "$PKG: up to date (v$OURS)"
fi
done
if [ "$NEEDS_SYNC" = "false" ]; then
echo "All packages are up to date"
fi
# Use the core pacer package version for branch naming
UPSTREAM_VERSION=$(git show upstream/main:packages/pacer/package.json | jq -r '.version')
echo "needs_sync=$NEEDS_SYNC" >> "$GITHUB_OUTPUT"
echo "upstream_version=$UPSTREAM_VERSION" >> "$GITHUB_OUTPUT"
echo "date=$(git log -1 --format='%cd' --date=format:'%Y%m%d' upstream/main)" >> "$GITHUB_OUTPUT"
# Store changed list for PR body (base64 to preserve newlines)
echo "changed=$(echo -e "$CHANGED" | base64 -w 0)" >> "$GITHUB_OUTPUT"
- name: Check for existing PR or branch
if: steps.compare.outputs.needs_sync == 'true'
id: check_pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pin gh to the fork. Without this, gh defaults to the parent
# (TanStack/pacer) because this is a fork — pr list would query
# the wrong repo and pr create would 403 on the upstream.
GH_REPO: ${{ github.repository }}
run: |
VERSION=${{ steps.compare.outputs.upstream_version }}
DATE=${{ steps.compare.outputs.date }}
BRANCH="sync/v${VERSION}-${DATE}"
# Match PRs in ANY state. A closed (not merged) sync PR means this
# version was deliberately rejected — re-creating it would also fail
# at `gh pr create`, which refuses a second PR for a head that already
# has one. Treating closed as "exists" skips the sync entirely until
# the branch is removed by hand.
EXISTING=$(gh pr list --head "$BRANCH" --state all --json number --jq 'length')
if [ "$EXISTING" -gt 0 ]; then
echo "PR already exists for $BRANCH"
echo "pr_exists=true" >> "$GITHUB_OUTPUT"
else
echo "pr_exists=false" >> "$GITHUB_OUTPUT"
fi
# The sync branch can outlive the run that created it: a prior run may
# have pushed it but failed before opening a PR. Because the branch
# name is derived from the upstream commit date, every subsequent run
# computes the same name, re-creates the branch from origin/main, and
# produces a diverged history that git rejects on push as
# non-fast-forward — wedging the workflow until the branch is removed
# by hand. Detect the existing remote branch so we skip the merge/push
# and just open the PR against what's there. (A branch whose PR was
# closed is already handled above via pr_exists.)
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "Branch $BRANCH already exists on remote"
echo "branch_exists=true" >> "$GITHUB_OUTPUT"
else
echo "branch_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Tools
if: steps.compare.outputs.needs_sync == 'true' && steps.check_pr.outputs.pr_exists == 'false' && steps.check_pr.outputs.branch_exists == 'false'
uses: tanstack/config/.github/setup@main
- name: Merge upstream into fork branch
if: steps.compare.outputs.needs_sync == 'true' && steps.check_pr.outputs.pr_exists == 'false' && steps.check_pr.outputs.branch_exists == 'false'
id: merge
run: |
VERSION=${{ steps.compare.outputs.upstream_version }}
DATE=${{ steps.compare.outputs.date }}
BRANCH="sync/v${VERSION}-${DATE}"
# Create branch from our main (preserving fork commits)
git checkout -b "$BRANCH" origin/main
# Configure git for the merge
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Attempt the merge — allow failure so we can auto-resolve conflicts
echo "Merging upstream/main into fork branch..."
if git merge upstream/main -m "Merge upstream TanStack Pacer v${VERSION} into fork"; then
echo "Merge completed cleanly"
echo "auto_resolved=" >> "$GITHUB_OUTPUT"
git push origin "$BRANCH"
exit 0
fi
echo "Merge has conflicts — attempting auto-resolution..."
CONFLICTED=$(git diff --name-only --diff-filter=U)
# ── Auto-resolution rules ─────────────────────────────────────
# Each rule handles a category of files that we never customize
# in the fork and can safely resolve automatically.
AUTO_RESOLVED=""
UNRESOLVED=""
for FILE in $CONFLICTED; do
case "$FILE" in
# Generated docs — take upstream; autofix CI regenerates them
docs/*)
git checkout --theirs -- "$FILE" && git add "$FILE"
AUTO_RESOLVED="${AUTO_RESOLVED} ${FILE} (took upstream — generated docs)\n"
;;
# Files the fork intentionally removed — keep deleted. Upstream
# still ships these, so an upstream edit surfaces as a
# modify/delete conflict that we resolve back to "deleted":
# - release.yml: fork publishes via publish-fork.yml
# - zizmor.yml: fork does not run the zizmor security scan
# - CODEOWNERS: fork does not use upstream's code owners
.github/workflows/release.yml | .github/workflows/zizmor.yml | .github/CODEOWNERS)
git rm -f "$FILE" 2>/dev/null || true
AUTO_RESOLVED="${AUTO_RESOLVED} ${FILE} (kept deleted — removed in fork)\n"
;;
# PR workflow — keep fork's simplified version (no NX Cloud, no
# nx-set-shas, no preview job, uses test:ci). The fork has
# intentionally diverged here; upstream changes are not adopted
# automatically and can be cherry-picked manually if desired.
.github/workflows/pr.yml)
git checkout --ours -- "$FILE" && git add "$FILE"
AUTO_RESOLVED="${AUTO_RESOLVED} ${FILE} (kept fork version — simplified PR workflow)\n"
;;
# Upstream changelogs — take upstream; fork doesn't add entries
packages/*/CHANGELOG.md)
git checkout --theirs -- "$FILE" && git add "$FILE"
AUTO_RESOLVED="${AUTO_RESOLVED} ${FILE} (took upstream — changelog)\n"
;;
# Lockfile — take upstream, then reconcile with fork's root package.json
pnpm-lock.yaml)
git checkout --theirs -- "$FILE" && git add "$FILE"
AUTO_RESOLVED="${AUTO_RESOLVED} ${FILE} (took upstream + pnpm install to reconcile)\n"
NEEDS_LOCKFILE_RECONCILE=true
;;
# Example app package.json — take upstream; fork doesn't modify examples
examples/*/package.json | examples/*/*/package.json)
git checkout --theirs -- "$FILE" && git add "$FILE"
AUTO_RESOLVED="${AUTO_RESOLVED} ${FILE} (took upstream — example dep versions)\n"
;;
# Everything else requires manual review
*)
UNRESOLVED="${UNRESOLVED} ${FILE}\n"
;;
esac
done
if [ -n "$AUTO_RESOLVED" ]; then
echo "Auto-resolved conflicts:"
echo -e "$AUTO_RESOLVED"
fi
# If any conflicts remain that we can't auto-resolve, abort
REMAINING=$(git diff --name-only --diff-filter=U)
if [ -n "$REMAINING" ]; then
echo "::error::Unable to auto-resolve all conflicts. Manual intervention required:"
echo -e "$UNRESOLVED"
git merge --abort
exit 1
fi
# Reconcile lockfile if it was conflict-resolved
if [ "${NEEDS_LOCKFILE_RECONCILE:-}" = "true" ]; then
echo "Running pnpm install to reconcile lockfile with fork's package.json..."
pnpm install --no-frozen-lockfile
git add pnpm-lock.yaml
fi
# All conflicts resolved — complete the merge
git commit --no-edit
echo "auto_resolved=$(echo -e "$AUTO_RESOLVED" | base64 -w 0)" >> "$GITHUB_OUTPUT"
git push origin "$BRANCH"
- name: Create PR
if: steps.compare.outputs.needs_sync == 'true' && steps.check_pr.outputs.pr_exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
VERSION=${{ steps.compare.outputs.upstream_version }}
DATE=${{ steps.compare.outputs.date }}
BRANCH="sync/v${VERSION}-${DATE}"
CHANGED=$(echo "${{ steps.compare.outputs.changed }}" | base64 -d)
AUTO_RESOLVED=""
if [ -n "${{ steps.merge.outputs.auto_resolved }}" ]; then
DECODED=$(echo "${{ steps.merge.outputs.auto_resolved }}" | base64 -d)
AUTO_RESOLVED="### Auto-resolved conflicts
\`\`\`
${DECODED}
\`\`\`
"
fi
gh pr create \
--base main \
--head "$BRANCH" \
--title "Sync upstream TanStack Pacer v${VERSION} (${DATE})" \
--body "## Upstream sync
New upstream release detected. This PR merges upstream \`main\` into our fork.
### Changed packages
${CHANGED}
${AUTO_RESOLVED}### After merging
The [publish-fork](${{ github.server_url }}/${{ github.repository }}/actions/workflows/publish-fork.yml) workflow will automatically publish any packages with changed versions.
---
*Auto-generated by the [sync-upstream](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.*"