Skip to content

sync-upstream

sync-upstream #246

Workflow file for this run

name: sync-upstream
on:
schedule:
- cron: "0 */2 * * *"
workflow_dispatch:
inputs:
force:
description: "Run sync even if open issues exist"
required: false
type: boolean
default: false
concurrency:
group: sync-upstream
cancel-in-progress: true
permissions:
contents: write
issues: write
id-token: write
jobs:
preflight:
runs-on: ubuntu-24.04
outputs:
should_skip: ${{ steps.check.outputs.should_skip }}
blocking_issue: ${{ steps.check.outputs.blocking_issue }}
steps:
- name: Check for open sync-failure issues
id: check
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
echo "Force flag set — skipping preflight check"
echo "should_skip=false" >> "$GITHUB_OUTPUT"
echo "blocking_issue=" >> "$GITHUB_OUTPUT"
exit 0
fi
should_skip=false
blocking_issue=""
for label in sync-conflict sync-e2e-failure sync-push-failure; do
result=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--label "$label" \
--json number,title \
--limit 1)
count=$(echo "$result" | jq length)
if [ "$count" -gt 0 ]; then
should_skip=true
issue_number=$(echo "$result" | jq -r '.[0].number')
issue_title=$(echo "$result" | jq -r '.[0].title')
blocking_issue="#${issue_number}: ${issue_title} (label: ${label})"
break
fi
done
echo "should_skip=$should_skip" >> "$GITHUB_OUTPUT"
echo "blocking_issue=$blocking_issue" >> "$GITHUB_OUTPUT"
- name: Report skip reason
if: steps.check.outputs.should_skip == 'true'
run: |
echo "::warning::Sync skipped — unresolved issue exists: ${{ steps.check.outputs.blocking_issue }}"
echo "## Sync Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "An unresolved sync-failure issue is still open:" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**${{ steps.check.outputs.blocking_issue }}**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The sync workflow will not run until this issue is closed." >> "$GITHUB_STEP_SUMMARY"
sync:
needs: preflight
if: needs.preflight.outputs.should_skip != 'true'
runs-on: ubuntu-24.04
timeout-minutes: 240
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
- name: Clear local tags before sync
run: |
# Upstream has rewritten existing tag names before. Clear local tags so
# subsequent sync fetches don't fail on tag clobber errors.
tags="$(git tag -l)"
if [ -z "$tags" ]; then
echo "No local tags to delete."
exit 0
fi
echo "$tags" | xargs -n 100 git tag -d
echo "Cleared local tags."
- name: Configure Git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Verify parent-dev mirror health
run: ./script/verify-upstream-mirror.sh
# Sync-upstream defers dependency installation to the post-merge test gate.
# This avoids pre-merge frozen-lockfile failures and allows bun.lock refreshes.
- name: Setup Bun
uses: ./.github/actions/setup-bun
with:
install: "false"
- name: Verify git push credentials
run: |
echo "Verifying push access to origin..."
git ls-remote --exit-code origin HEAD > /dev/null
echo "Push credentials verified."
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
# ── Phase 1: Merge ────────────────────────────────────
- name: Sync upstream (merge phase)
id: sync
run: ./script/sync-upstream.ts --phase merge
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
# ── Phase 2: Resolve conflicts (Claude) ───────────────
- name: Resolve merge conflicts with Claude
id: claude_resolve
if: steps.sync.outputs.has_conflicts == 'true'
uses: anthropics/claude-code-action@v1
timeout-minutes: 60
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
show_full_output: true
prompt: |
This repository is a fork of anomalyco/opencode being synced with upstream.
A `git merge parent-dev` produced conflicts in these files:
${{ steps.sync.outputs.conflicted_files }}
## Context
This fork maintains custom features layered on top of upstream. Read
`docs/upstream-sync/fork-feature-audit.md` — it is the authoritative
inventory of fork-specific features and file ownership.
Fork-specific behavior lives in `packages/fork-*` with thin re-export
stubs in the main codebase. Upstream changes to core packages should
generally be accepted.
## Resolution Rules
For each conflicted file:
1. Read `docs/upstream-sync/fork-feature-audit.md` to determine ownership.
2. Upstream-owned files (not in audit): accept upstream version entirely.
3. Fork-owned files (`packages/fork-*`): preserve fork version, incorporate
non-contradicting upstream changes.
4. Shared surfaces (files in `packages/opencode/src/` that import from
`packages/fork-*`): These are the hardest conflicts. For each file:
a. Read BOTH versions: `git show HEAD:<path>` (fork) and
`git show MERGE_HEAD:<path>` (upstream).
b. Start from the UPSTREAM version as the base — accept all upstream
structural changes, new features, new parameters, and refactors.
c. Then surgically re-add fork-specific code from the fork version:
fork-* package imports, broker/auth state blocks, and fallback
paths in exported functions that delegate to fork implementations.
d. Ensure upstream's new features are preserved in the final result.
e. The goal is: upstream's new code + fork's integration hooks on top.
5. Config/infra files: merge both sides, keep fork additions alongside
upstream updates.
## Steps
1. Read `docs/upstream-sync/fork-feature-audit.md`.
2. Resolve each conflicted file per the rules. Remove ALL conflict markers.
3. Stage resolved files with `git add`.
4. Run `git commit --no-edit` to finalize the merge commit.
Do NOT run tests or push. Do NOT make changes beyond conflict resolution.
claude_args: >-
--max-turns 300
--model claude-opus-4-6
--allowedTools "Edit,Write,Bash(*)"
- name: Save Claude output (conflict resolution)
if: always() && steps.sync.outputs.has_conflicts == 'true'
run: cp -f "$RUNNER_TEMP/claude-execution-output.json" "$RUNNER_TEMP/claude-resolve.json" 2>/dev/null || true
# After merge/resolution, package.json may require a newer Bun version
# from upstream. Re-setup to match the merged state.
# Keep install disabled here too; runTestGate performs the post-merge bun install.
- name: Re-setup Bun (post-merge)
if: steps.sync.outputs.sync_branch != ''
uses: ./.github/actions/setup-bun
with:
install: "false"
# ── Phase 3: Run tests ────────────────────────────────
- name: Run tests after merge/resolution
id: test1
if: steps.sync.outputs.sync_branch != ''
run: ./script/sync-upstream.ts --phase test
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
continue-on-error: true
# ── Fix attempt 1 ────────────────────────────────────
- name: Fix test failures with Claude (attempt 1)
id: claude_fix1
if: steps.test1.outputs.tests_passed == 'false'
uses: anthropics/claude-code-action@v1
timeout-minutes: 60
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
show_full_output: true
prompt: |
This repository is a fork of anomalyco/opencode. An upstream sync merge
succeeded but typecheck or e2e tests are failing:
${{ steps.test1.outputs.test_failures }}
Read `docs/upstream-sync/fork-feature-audit.md` for context on which
files are fork-owned vs upstream-owned.
When fixing shared surface files (files in `packages/opencode/src/` that
import from fork-* packages), compare the current file against both
`git show origin/dev:<path>` (fork before merge) and the upstream version
to ensure all fork hooks are preserved alongside upstream's new features.
Before committing, run:
- `bun run rules:parity:check`
- `bun run fork:boundary:check`
- `bun run sdk:parity:check`
If SDK parity fails due generated drift, run `./packages/sdk/js/script/build.ts`
and re-run parity check.
Fix the errors. Commit your changes only after parity checks pass.
Do NOT run tests or push.
claude_args: >-
--max-turns 200
--model claude-opus-4-6
--allowedTools "Edit,Write,Bash(*)"
- name: Save Claude output (fix attempt 1)
if: always() && steps.test1.outputs.tests_passed == 'false'
run: cp -f "$RUNNER_TEMP/claude-execution-output.json" "$RUNNER_TEMP/claude-fix1.json" 2>/dev/null || true
- name: Run tests (attempt 2)
id: test2
if: steps.test1.outputs.tests_passed == 'false'
run: ./script/sync-upstream.ts --phase test
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
continue-on-error: true
# ── Fix attempt 2 ────────────────────────────────────
- name: Fix test failures with Claude (attempt 2)
id: claude_fix2
if: steps.test2.outputs.tests_passed == 'false'
uses: anthropics/claude-code-action@v1
timeout-minutes: 60
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
show_full_output: true
prompt: |
Upstream sync tests still failing after first fix attempt:
${{ steps.test2.outputs.test_failures }}
Read `docs/upstream-sync/fork-feature-audit.md` for context.
When fixing shared surface files (files in `packages/opencode/src/` that
import from fork-* packages), compare the current file against both
`git show origin/dev:<path>` (fork before merge) and the upstream version
to ensure all fork hooks are preserved alongside upstream's new features.
Before committing, run:
- `bun run rules:parity:check`
- `bun run fork:boundary:check`
- `bun run sdk:parity:check`
If SDK parity fails due generated drift, run `./packages/sdk/js/script/build.ts`
and re-run parity check.
Fix the errors. Commit your changes only after parity checks pass.
Do NOT run tests or push.
claude_args: >-
--max-turns 200
--model claude-opus-4-6
--allowedTools "Edit,Write,Bash(*)"
- name: Save Claude output (fix attempt 2)
if: always() && steps.test2.outputs.tests_passed == 'false'
run: cp -f "$RUNNER_TEMP/claude-execution-output.json" "$RUNNER_TEMP/claude-fix2.json" 2>/dev/null || true
- name: Run tests (attempt 3)
id: test3
if: steps.test2.outputs.tests_passed == 'false'
run: ./script/sync-upstream.ts --phase test
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
continue-on-error: true
# ── Claude usage summary ──────────────────────────────
- name: Claude usage summary
if: always() && steps.sync.outputs.sync_branch != ''
run: |
declare -A phase_labels=( [resolve]="conflict resolution" [fix1]="fix attempt 1" [fix2]="fix attempt 2" )
declare -A phase_files=( [resolve]="$RUNNER_TEMP/claude-resolve.json" [fix1]="$RUNNER_TEMP/claude-fix1.json" [fix2]="$RUNNER_TEMP/claude-fix2.json" )
total_turns=0 total_tool_calls=0 total_duration_ms=0 total_api_duration_ms=0
total_input_tokens=0 total_output_tokens=0 total_cache_write_tokens=0 total_cache_read_tokens=0 total_cost=0
any_phase_ran=false
format_duration() {
local milliseconds=$1
local seconds=$(( milliseconds / 1000 ))
printf '%dm %ds' $(( seconds / 60 )) $(( seconds % 60 ))
}
format_number() {
printf "%'d" "$1"
}
echo "══════════════════════════════════════════"
echo " Claude Usage Summary"
echo "══════════════════════════════════════════"
for phase_key in resolve fix1 fix2; do
phase_file="${phase_files[$phase_key]}"
phase_label="${phase_labels[$phase_key]}"
echo ""
echo "── $phase_label ──"
if [ ! -f "$phase_file" ]; then
echo " (skipped)"
continue
fi
any_phase_ran=true
result_message=$(jq -r '.[] | select(.type == "result")' "$phase_file")
if [ -z "$result_message" ] || [ "$result_message" = "null" ]; then
echo " (no result data)"
continue
fi
turns=$(echo "$result_message" | jq -r '.num_turns // 0')
cost=$(echo "$result_message" | jq -r '.total_cost_usd // 0')
duration_ms=$(echo "$result_message" | jq -r '.duration_ms // 0')
api_duration_ms=$(echo "$result_message" | jq -r '.duration_api_ms // 0')
input_tokens=$(echo "$result_message" | jq -r '.usage.input_tokens // 0')
output_tokens=$(echo "$result_message" | jq -r '.usage.output_tokens // 0')
cache_write_tokens=$(echo "$result_message" | jq -r '.usage.cache_creation_input_tokens // 0')
cache_read_tokens=$(echo "$result_message" | jq -r '.usage.cache_read_input_tokens // 0')
tool_calls=$(jq '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length' "$phase_file")
echo " Turns: $turns"
echo " Tool calls: $tool_calls"
echo " Duration: $(format_duration "$duration_ms") (API: $(format_duration "$api_duration_ms"))"
echo " Input: $(format_number "$input_tokens")"
echo " Output: $(format_number "$output_tokens")"
echo " Cache write: $(format_number "$cache_write_tokens")"
echo " Cache read: $(format_number "$cache_read_tokens")"
printf ' Cost: $%.4f\n' "$cost"
total_turns=$((total_turns + turns))
total_tool_calls=$((total_tool_calls + tool_calls))
total_duration_ms=$((total_duration_ms + duration_ms))
total_api_duration_ms=$((total_api_duration_ms + api_duration_ms))
total_input_tokens=$((total_input_tokens + input_tokens))
total_output_tokens=$((total_output_tokens + output_tokens))
total_cache_write_tokens=$((total_cache_write_tokens + cache_write_tokens))
total_cache_read_tokens=$((total_cache_read_tokens + cache_read_tokens))
total_cost=$(echo "$total_cost + $cost" | bc)
done
if [ "$any_phase_ran" = true ]; then
echo ""
echo "── TOTALS ──"
echo " Turns: $total_turns"
echo " Tool calls: $total_tool_calls"
echo " Duration: $(format_duration "$total_duration_ms") (API: $(format_duration "$total_api_duration_ms"))"
echo " Input: $(format_number "$total_input_tokens")"
echo " Output: $(format_number "$total_output_tokens")"
echo " Cache write: $(format_number "$total_cache_write_tokens")"
echo " Cache read: $(format_number "$total_cache_read_tokens")"
printf ' Cost: $%.4f\n' "$total_cost"
else
echo ""
echo " No Claude steps ran."
fi
echo "══════════════════════════════════════════"
# ── Determine final test result ───────────────────────
- name: Determine final test status
id: final
if: steps.sync.outputs.sync_branch != ''
run: |
# Find the last test step that actually ran (priority: test3 > test2 > test1)
for result in \
"${{ steps.test3.outputs.tests_passed }}" \
"${{ steps.test2.outputs.tests_passed }}" \
"${{ steps.test1.outputs.tests_passed }}"; do
if [ -n "$result" ]; then
echo "tests_passed=$result" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "tests_passed=false" >> "$GITHUB_OUTPUT"
# ── Merge + push to dev (success path) ────────────────
- name: Post-resolve sync (merge + push to dev)
if: steps.final.outputs.tests_passed == 'true'
run: |
./script/sync-upstream.ts \
--phase post-resolve \
--branch "${{ steps.sync.outputs.sync_branch }}" \
--merge-base "${{ steps.sync.outputs.merge_base }}" \
--behind "${{ steps.sync.outputs.behind }}" \
--ahead "${{ steps.sync.outputs.ahead }}" \
--claude-resolved "${{ steps.sync.outputs.has_conflicts == 'true' || steps.test1.outputs.tests_passed == 'false' }}"
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
# ── Fallback: create issue ────────────────────────────
- name: Create issue (Claude unable to fix)
if: steps.final.outputs.tests_passed == 'false'
run: |
./script/sync-upstream.ts \
--phase create-issue \
--branch "${{ steps.sync.outputs.sync_branch }}" \
--merge-base "${{ steps.sync.outputs.merge_base }}" \
--behind "${{ steps.sync.outputs.behind }}" \
--ahead "${{ steps.sync.outputs.ahead }}" \
--had-conflicts "${{ steps.sync.outputs.has_conflicts }}"
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
UPSTREAM_SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }}