Skip to content

Commit 994123d

Browse files
chore(ci): add fork sync and janitor workflows (#1)
1 parent 958320f commit 994123d

3 files changed

Lines changed: 500 additions & 0 deletions

File tree

.github/workflows/fork-janitor.yml

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
name: fork-janitor
2+
3+
on:
4+
workflow_run:
5+
workflows: ["fork-sync-upstream"]
6+
types: [completed]
7+
schedule:
8+
- cron: "47 */6 * * *"
9+
workflow_dispatch:
10+
inputs:
11+
pr_number:
12+
description: "Specific sync PR number to repair"
13+
required: false
14+
upstream_branch:
15+
description: "Upstream branch to rebase onto"
16+
required: false
17+
default: "dev"
18+
base_branch:
19+
description: "Fork base branch for sync PRs"
20+
required: false
21+
default: "dev"
22+
23+
permissions:
24+
contents: write
25+
pull-requests: write
26+
issues: write
27+
actions: read
28+
29+
concurrency:
30+
group: fork-janitor-${{ github.repository }}
31+
cancel-in-progress: false
32+
33+
jobs:
34+
janitor:
35+
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'cancelled'
36+
runs-on: ubuntu-latest
37+
env:
38+
UPSTREAM_REPO: anomalyco/opencode
39+
UPSTREAM_BRANCH: ${{ github.event.inputs.upstream_branch || vars.FORK_UPSTREAM_BRANCH || 'dev' }}
40+
BASE_BRANCH: ${{ github.event.inputs.base_branch || vars.FORK_SYNC_BASE_BRANCH || 'dev' }}
41+
PATCH_BRANCH: ${{ vars.FORK_PATCH_BRANCH || 'feat/hot-reload-smooth' }}
42+
JANITOR_MODEL: ${{ vars.FORK_JANITOR_MODEL || 'openai/gpt-4.1' }}
43+
INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number || '' }}
44+
steps:
45+
- name: Checkout
46+
uses: actions/checkout@v4
47+
with:
48+
fetch-depth: 0
49+
ref: ${{ env.BASE_BRANCH }}
50+
51+
- name: Configure git
52+
run: |
53+
git config user.name "github-actions[bot]"
54+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
55+
56+
- name: Fetch upstream and origin
57+
run: |
58+
set -euo pipefail
59+
git remote remove upstream 2>/dev/null || true
60+
git remote add upstream "https://github.com/${UPSTREAM_REPO}.git"
61+
git fetch --prune origin
62+
git fetch --prune upstream "${UPSTREAM_BRANCH}"
63+
64+
- name: Select target sync PR
65+
id: target
66+
env:
67+
GH_TOKEN: ${{ github.token }}
68+
run: |
69+
set -euo pipefail
70+
71+
pr_number="${INPUT_PR_NUMBER}"
72+
if [ -n "$pr_number" ]; then
73+
head_branch="$(gh pr view "$pr_number" --repo "$GITHUB_REPOSITORY" --json headRefName --jq '.headRefName')"
74+
echo "needs_fix=true" >> "$GITHUB_OUTPUT"
75+
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
76+
echo "head_branch=$head_branch" >> "$GITHUB_OUTPUT"
77+
echo "reason=manual_dispatch" >> "$GITHUB_OUTPUT"
78+
exit 0
79+
fi
80+
81+
gh pr list \
82+
--repo "$GITHUB_REPOSITORY" \
83+
--state open \
84+
--base "$BASE_BRANCH" \
85+
--label fork-sync \
86+
--limit 30 \
87+
--json number,headRefName,url,title,mergeStateStatus,statusCheckRollup,updatedAt > /tmp/fork_sync_prs.json
88+
89+
candidate="$(jq -c '
90+
map(select(
91+
(.mergeStateStatus == "DIRTY") or
92+
(.mergeStateStatus == "BEHIND") or
93+
(.mergeStateStatus == "BLOCKED") or
94+
([.statusCheckRollup[]? | select(
95+
.conclusion == "FAILURE" or
96+
.conclusion == "TIMED_OUT" or
97+
.conclusion == "CANCELLED" or
98+
.conclusion == "ACTION_REQUIRED" or
99+
.conclusion == "STARTUP_FAILURE"
100+
)] | length > 0)
101+
))
102+
| sort_by(.updatedAt)
103+
| reverse
104+
| .[0] // empty
105+
' /tmp/fork_sync_prs.json)"
106+
107+
if [ -z "$candidate" ] || [ "$candidate" = "null" ]; then
108+
echo "needs_fix=false" >> "$GITHUB_OUTPUT"
109+
exit 0
110+
fi
111+
112+
pr_number="$(echo "$candidate" | jq -r '.number')"
113+
head_branch="$(echo "$candidate" | jq -r '.headRefName')"
114+
merge_state="$(echo "$candidate" | jq -r '.mergeStateStatus')"
115+
116+
echo "needs_fix=true" >> "$GITHUB_OUTPUT"
117+
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
118+
echo "head_branch=$head_branch" >> "$GITHUB_OUTPUT"
119+
echo "reason=$merge_state" >> "$GITHUB_OUTPUT"
120+
121+
- name: Skip when nothing needs repair
122+
if: steps.target.outputs.needs_fix != 'true'
123+
run: echo "No sync PR currently needs janitor repair."
124+
125+
- name: Check OPENAI_API_KEY
126+
id: key
127+
if: steps.target.outputs.needs_fix == 'true'
128+
env:
129+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
130+
run: |
131+
set -euo pipefail
132+
if [ -z "${OPENAI_API_KEY:-}" ]; then
133+
echo "configured=false" >> "$GITHUB_OUTPUT"
134+
exit 0
135+
fi
136+
echo "configured=true" >> "$GITHUB_OUTPUT"
137+
138+
- name: Comment when OPENAI_API_KEY is missing
139+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured != 'true'
140+
env:
141+
GH_TOKEN: ${{ github.token }}
142+
run: |
143+
gh pr comment "${{ steps.target.outputs.pr_number }}" \
144+
--repo "$GITHUB_REPOSITORY" \
145+
--body "Janitor skipped because OPENAI_API_KEY is not configured in repository secrets."
146+
147+
- name: Checkout target branch
148+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
149+
run: |
150+
set -euo pipefail
151+
git fetch --prune origin "${{ steps.target.outputs.head_branch }}"
152+
git checkout -B "${{ steps.target.outputs.head_branch }}" "origin/${{ steps.target.outputs.head_branch }}"
153+
154+
- name: Attempt upstream rebase
155+
id: rebase
156+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
157+
run: |
158+
set -euo pipefail
159+
rebase_ok=true
160+
if ! git rebase "upstream/${UPSTREAM_BRANCH}"; then
161+
rebase_ok=false
162+
fi
163+
conflicts="$(git diff --name-only --diff-filter=U | tr '\n' ' ' | sed 's/[[:space:]]\+$//')"
164+
echo "rebase_ok=$rebase_ok" >> "$GITHUB_OUTPUT"
165+
echo "conflicts=$conflicts" >> "$GITHUB_OUTPUT"
166+
167+
- name: Build janitor context
168+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
169+
env:
170+
GH_TOKEN: ${{ github.token }}
171+
PR_NUMBER: ${{ steps.target.outputs.pr_number }}
172+
run: |
173+
set -euo pipefail
174+
175+
gh pr view "$PR_NUMBER" \
176+
--repo "$GITHUB_REPOSITORY" \
177+
--json number,title,body,url,baseRefName,headRefName,mergeStateStatus,statusCheckRollup > /tmp/janitor-pr.json
178+
179+
{
180+
echo "You are the fork janitor for ${GITHUB_REPOSITORY}."
181+
echo ""
182+
echo "Goal: repair the current sync branch so upstream updates can land while preserving downstream behavior from ${PATCH_BRANCH:-feat/hot-reload-smooth}."
183+
echo ""
184+
echo "Repository state:"
185+
echo "- base branch: ${BASE_BRANCH}"
186+
echo "- upstream branch: ${UPSTREAM_BRANCH}"
187+
echo "- target PR number: ${PR_NUMBER}"
188+
echo "- target branch: ${{ steps.target.outputs.head_branch }}"
189+
echo "- sync reason: ${{ steps.target.outputs.reason }}"
190+
echo "- rebase ok: ${{ steps.rebase.outputs.rebase_ok }}"
191+
echo "- conflicts: ${{ steps.rebase.outputs.conflicts }}"
192+
echo ""
193+
echo "Tasks (in order):"
194+
echo "1. If a rebase is in progress, resolve conflicts and complete the rebase onto upstream/${UPSTREAM_BRANCH}."
195+
echo "2. Keep the downstream patch behavior intact (hot-reload fixes from the fork patch branch)."
196+
echo "3. Run a focused validation command and fix obvious breakages (prefer 'bun run typecheck')."
197+
echo "4. Stage all needed changes and create a single commit with message: chore(sync): janitor repair upstream sync"
198+
echo "5. Do not push; workflow will push."
199+
echo ""
200+
echo "Constraints:"
201+
echo "- Touch only files needed for merge/conflict/test repair."
202+
echo "- Never delete workflows unrelated to sync/release."
203+
echo "- Never change release tags or package versions in this janitor pass."
204+
} > /tmp/janitor-prompt.txt
205+
206+
- name: Install opencode
207+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
208+
run: curl -fsSL https://opencode.ai/install | bash
209+
210+
- name: Run janitor LLM repair
211+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
212+
env:
213+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
214+
GITHUB_TOKEN: ${{ github.token }}
215+
OPENCODE_PERMISSION: '{"bash":{"*":"deny","git*":"allow","bun*":"allow","pnpm*":"allow","gh*":"allow"}}'
216+
run: |
217+
opencode run -m "$JANITOR_MODEL" "$(cat /tmp/janitor-prompt.txt)"
218+
219+
- name: Finalize branch and push
220+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
221+
run: |
222+
set -euo pipefail
223+
224+
unresolved="$(git diff --name-only --diff-filter=U)"
225+
if [ -n "$unresolved" ]; then
226+
echo "Unresolved conflicts remain:" >&2
227+
echo "$unresolved" >&2
228+
exit 1
229+
fi
230+
231+
if [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; then
232+
echo "Rebase still in progress after janitor pass." >&2
233+
exit 1
234+
fi
235+
236+
if [ -n "$(git status --porcelain)" ]; then
237+
git add -A
238+
if ! git diff --cached --quiet; then
239+
git commit -m "chore(sync): janitor repair upstream sync"
240+
fi
241+
fi
242+
243+
git push --force-with-lease origin "${{ steps.target.outputs.head_branch }}"
244+
245+
- name: Update PR labels and comment
246+
if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true'
247+
env:
248+
GH_TOKEN: ${{ github.token }}
249+
PR_NUMBER: ${{ steps.target.outputs.pr_number }}
250+
run: |
251+
set -euo pipefail
252+
253+
unresolved="$(git diff --name-only --diff-filter=U | tr '\n' ' ' | sed 's/[[:space:]]\+$//')"
254+
if [ -n "$unresolved" ]; then
255+
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "Janitor ran but unresolved conflicts remain: $unresolved"
256+
gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label sync-conflict --add-label janitor-needed
257+
exit 1
258+
fi
259+
260+
gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label sync-conflict 2>/dev/null || true
261+
gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label janitor-needed 2>/dev/null || true
262+
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "Janitor repair pass completed and pushed updates to `${{ steps.target.outputs.head_branch }}`."

0 commit comments

Comments
 (0)