diff --git a/.github/workflows/fork-janitor.yml b/.github/workflows/fork-janitor.yml new file mode 100644 index 000000000000..b7d5b0b4bfdb --- /dev/null +++ b/.github/workflows/fork-janitor.yml @@ -0,0 +1,262 @@ +name: fork-janitor + +on: + workflow_run: + workflows: ["fork-sync-upstream"] + types: [completed] + schedule: + - cron: "47 */6 * * *" + workflow_dispatch: + inputs: + pr_number: + description: "Specific sync PR number to repair" + required: false + upstream_branch: + description: "Upstream branch to rebase onto" + required: false + default: "dev" + base_branch: + description: "Fork base branch for sync PRs" + required: false + default: "dev" + +permissions: + contents: write + pull-requests: write + issues: write + actions: read + +concurrency: + group: fork-janitor-${{ github.repository }} + cancel-in-progress: false + +jobs: + janitor: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'cancelled' + runs-on: ubuntu-latest + env: + UPSTREAM_REPO: anomalyco/opencode + UPSTREAM_BRANCH: ${{ github.event.inputs.upstream_branch || vars.FORK_UPSTREAM_BRANCH || 'dev' }} + BASE_BRANCH: ${{ github.event.inputs.base_branch || vars.FORK_SYNC_BASE_BRANCH || 'dev' }} + PATCH_BRANCH: ${{ vars.FORK_PATCH_BRANCH || 'feat/hot-reload-smooth' }} + JANITOR_MODEL: ${{ vars.FORK_JANITOR_MODEL || 'openai/gpt-4.1' }} + INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number || '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ env.BASE_BRANCH }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream and origin + run: | + set -euo pipefail + git remote remove upstream 2>/dev/null || true + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch --prune origin + git fetch --prune upstream "${UPSTREAM_BRANCH}" + + - name: Select target sync PR + id: target + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + pr_number="${INPUT_PR_NUMBER}" + if [ -n "$pr_number" ]; then + head_branch="$(gh pr view "$pr_number" --repo "$GITHUB_REPOSITORY" --json headRefName --jq '.headRefName')" + echo "needs_fix=true" >> "$GITHUB_OUTPUT" + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "head_branch=$head_branch" >> "$GITHUB_OUTPUT" + echo "reason=manual_dispatch" >> "$GITHUB_OUTPUT" + exit 0 + fi + + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --base "$BASE_BRANCH" \ + --label fork-sync \ + --limit 30 \ + --json number,headRefName,url,title,mergeStateStatus,statusCheckRollup,updatedAt > /tmp/fork_sync_prs.json + + candidate="$(jq -c ' + map(select( + (.mergeStateStatus == "DIRTY") or + (.mergeStateStatus == "BEHIND") or + (.mergeStateStatus == "BLOCKED") or + ([.statusCheckRollup[]? | select( + .conclusion == "FAILURE" or + .conclusion == "TIMED_OUT" or + .conclusion == "CANCELLED" or + .conclusion == "ACTION_REQUIRED" or + .conclusion == "STARTUP_FAILURE" + )] | length > 0) + )) + | sort_by(.updatedAt) + | reverse + | .[0] // empty + ' /tmp/fork_sync_prs.json)" + + if [ -z "$candidate" ] || [ "$candidate" = "null" ]; then + echo "needs_fix=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pr_number="$(echo "$candidate" | jq -r '.number')" + head_branch="$(echo "$candidate" | jq -r '.headRefName')" + merge_state="$(echo "$candidate" | jq -r '.mergeStateStatus')" + + echo "needs_fix=true" >> "$GITHUB_OUTPUT" + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "head_branch=$head_branch" >> "$GITHUB_OUTPUT" + echo "reason=$merge_state" >> "$GITHUB_OUTPUT" + + - name: Skip when nothing needs repair + if: steps.target.outputs.needs_fix != 'true' + run: echo "No sync PR currently needs janitor repair." + + - name: Check OPENAI_API_KEY + id: key + if: steps.target.outputs.needs_fix == 'true' + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + set -euo pipefail + if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "configured=true" >> "$GITHUB_OUTPUT" + + - name: Comment when OPENAI_API_KEY is missing + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment "${{ steps.target.outputs.pr_number }}" \ + --repo "$GITHUB_REPOSITORY" \ + --body "Janitor skipped because OPENAI_API_KEY is not configured in repository secrets." + + - name: Checkout target branch + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + git fetch --prune origin "${{ steps.target.outputs.head_branch }}" + git checkout -B "${{ steps.target.outputs.head_branch }}" "origin/${{ steps.target.outputs.head_branch }}" + + - name: Attempt upstream rebase + id: rebase + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + rebase_ok=true + if ! git rebase "upstream/${UPSTREAM_BRANCH}"; then + rebase_ok=false + fi + conflicts="$(git diff --name-only --diff-filter=U | tr '\n' ' ' | sed 's/[[:space:]]\+$//')" + echo "rebase_ok=$rebase_ok" >> "$GITHUB_OUTPUT" + echo "conflicts=$conflicts" >> "$GITHUB_OUTPUT" + + - name: Build janitor context + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.target.outputs.pr_number }} + run: | + set -euo pipefail + + gh pr view "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json number,title,body,url,baseRefName,headRefName,mergeStateStatus,statusCheckRollup > /tmp/janitor-pr.json + + { + echo "You are the fork janitor for ${GITHUB_REPOSITORY}." + echo "" + echo "Goal: repair the current sync branch so upstream updates can land while preserving downstream behavior from ${PATCH_BRANCH:-feat/hot-reload-smooth}." + echo "" + echo "Repository state:" + echo "- base branch: ${BASE_BRANCH}" + echo "- upstream branch: ${UPSTREAM_BRANCH}" + echo "- target PR number: ${PR_NUMBER}" + echo "- target branch: ${{ steps.target.outputs.head_branch }}" + echo "- sync reason: ${{ steps.target.outputs.reason }}" + echo "- rebase ok: ${{ steps.rebase.outputs.rebase_ok }}" + echo "- conflicts: ${{ steps.rebase.outputs.conflicts }}" + echo "" + echo "Tasks (in order):" + echo "1. If a rebase is in progress, resolve conflicts and complete the rebase onto upstream/${UPSTREAM_BRANCH}." + echo "2. Keep the downstream patch behavior intact (hot-reload fixes from the fork patch branch)." + echo "3. Run a focused validation command and fix obvious breakages (prefer 'bun run typecheck')." + echo "4. Stage all needed changes and create a single commit with message: chore(sync): janitor repair upstream sync" + echo "5. Do not push; workflow will push." + echo "" + echo "Constraints:" + echo "- Touch only files needed for merge/conflict/test repair." + echo "- Never delete workflows unrelated to sync/release." + echo "- Never change release tags or package versions in this janitor pass." + } > /tmp/janitor-prompt.txt + + - name: Install opencode + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + run: curl -fsSL https://opencode.ai/install | bash + + - name: Run janitor LLM repair + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GITHUB_TOKEN: ${{ github.token }} + OPENCODE_PERMISSION: '{"bash":{"*":"deny","git*":"allow","bun*":"allow","pnpm*":"allow","gh*":"allow"}}' + run: | + opencode run -m "$JANITOR_MODEL" "$(cat /tmp/janitor-prompt.txt)" + + - name: Finalize branch and push + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + + unresolved="$(git diff --name-only --diff-filter=U)" + if [ -n "$unresolved" ]; then + echo "Unresolved conflicts remain:" >&2 + echo "$unresolved" >&2 + exit 1 + fi + + if [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; then + echo "Rebase still in progress after janitor pass." >&2 + exit 1 + fi + + if [ -n "$(git status --porcelain)" ]; then + git add -A + if ! git diff --cached --quiet; then + git commit -m "chore(sync): janitor repair upstream sync" + fi + fi + + git push --force-with-lease origin "${{ steps.target.outputs.head_branch }}" + + - name: Update PR labels and comment + if: steps.target.outputs.needs_fix == 'true' && steps.key.outputs.configured == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.target.outputs.pr_number }} + run: | + set -euo pipefail + + unresolved="$(git diff --name-only --diff-filter=U | tr '\n' ' ' | sed 's/[[:space:]]\+$//')" + if [ -n "$unresolved" ]; then + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "Janitor ran but unresolved conflicts remain: $unresolved" + gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label sync-conflict --add-label janitor-needed + exit 1 + fi + + gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label sync-conflict 2>/dev/null || true + gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label janitor-needed 2>/dev/null || true + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "Janitor repair pass completed and pushed updates to `${{ steps.target.outputs.head_branch }}`." diff --git a/.github/workflows/fork-sync-upstream.yml b/.github/workflows/fork-sync-upstream.yml new file mode 100644 index 000000000000..cca603eac293 --- /dev/null +++ b/.github/workflows/fork-sync-upstream.yml @@ -0,0 +1,158 @@ +name: fork-sync-upstream + +on: + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + inputs: + upstream_branch: + description: "Upstream branch to sync from" + required: false + default: "dev" + patch_branch: + description: "Fork patch branch to preserve" + required: false + default: "feat/hot-reload-smooth" + base_branch: + description: "Fork base branch to open PR into" + required: false + default: "dev" + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: fork-sync-upstream-${{ github.repository }} + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + env: + UPSTREAM_REPO: anomalyco/opencode + UPSTREAM_BRANCH: ${{ github.event.inputs.upstream_branch || vars.FORK_UPSTREAM_BRANCH || 'dev' }} + PATCH_BRANCH: ${{ github.event.inputs.patch_branch || vars.FORK_PATCH_BRANCH || 'feat/hot-reload-smooth' }} + BASE_BRANCH: ${{ github.event.inputs.base_branch || vars.FORK_SYNC_BASE_BRANCH || 'dev' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ env.BASE_BRANCH }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Ensure labels exist + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create fork-sync --repo "$GITHUB_REPOSITORY" --color 1f6feb --description "Automated upstream sync PR" 2>/dev/null || true + gh label create sync-conflict --repo "$GITHUB_REPOSITORY" --color d73a4a --description "Sync PR currently has conflicts" 2>/dev/null || true + gh label create janitor-needed --repo "$GITHUB_REPOSITORY" --color fbca04 --description "Needs janitor repair pass" 2>/dev/null || true + + - name: Fetch upstream and fork branches + run: | + set -euo pipefail + git remote remove upstream 2>/dev/null || true + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch --prune origin "${BASE_BRANCH}" "${PATCH_BRANCH}" + git fetch --prune upstream "${UPSTREAM_BRANCH}" + + - name: Prepare sync branch + id: prep + run: | + set -euo pipefail + stamp="$(date -u +%Y%m%d-%H%M%S)" + upstream_sha="$(git rev-parse --short "upstream/${UPSTREAM_BRANCH}")" + sync_branch="sync/${UPSTREAM_BRANCH}-${stamp}-${upstream_sha}" + git checkout -B "$sync_branch" "origin/${PATCH_BRANCH}" + echo "sync_branch=$sync_branch" >> "$GITHUB_OUTPUT" + echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" + + - name: Rebase patch branch on upstream + id: rebase + run: | + set -euo pipefail + rebase_ok=true + conflicts="" + + if ! git rebase "upstream/${UPSTREAM_BRANCH}"; then + rebase_ok=false + conflicts="$(git diff --name-only --diff-filter=U | tr '\n' ' ' | sed 's/[[:space:]]\+$//')" + git rebase --abort || true + fi + + echo "rebase_ok=$rebase_ok" >> "$GITHUB_OUTPUT" + echo "conflicts=$conflicts" >> "$GITHUB_OUTPUT" + + - name: Push sync branch + run: | + set -euo pipefail + git push --force-with-lease origin "${{ steps.prep.outputs.sync_branch }}" + + - name: Open or update sync PR + id: pr + env: + GH_TOKEN: ${{ github.token }} + SYNC_BRANCH: ${{ steps.prep.outputs.sync_branch }} + REBASE_OK: ${{ steps.rebase.outputs.rebase_ok }} + CONFLICTS: ${{ steps.rebase.outputs.conflicts }} + UPSTREAM_SHA: ${{ steps.prep.outputs.upstream_sha }} + run: | + set -euo pipefail + + body_file="$(mktemp)" + { + echo "## Summary" + echo "- Upstream: ${UPSTREAM_REPO}@${UPSTREAM_BRANCH} (${UPSTREAM_SHA})" + echo "- Patch branch: ${PATCH_BRANCH}" + echo "- Base branch: ${BASE_BRANCH}" + echo "- Rebase status: ${REBASE_OK}" + if [ "${REBASE_OK}" != "true" ]; then + echo "" + echo "## Conflict Details" + echo "- Conflicting files: ${CONFLICTS:-none reported}" + echo "- Label `janitor-needed` added; janitor workflow will attempt repair." + fi + } > "$body_file" + + pr_number="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "$SYNC_BRANCH" --base "$BASE_BRANCH" --json number --jq '.[0].number // empty')" + + if [ -n "$pr_number" ]; then + gh pr edit "$pr_number" --repo "$GITHUB_REPOSITORY" --title "chore(sync): ${PATCH_BRANCH} onto upstream/${UPSTREAM_BRANCH}" --body-file "$body_file" + else + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$BASE_BRANCH" \ + --head "$SYNC_BRANCH" \ + --title "chore(sync): ${PATCH_BRANCH} onto upstream/${UPSTREAM_BRANCH}" \ + --body-file "$body_file" + pr_number="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "$SYNC_BRANCH" --base "$BASE_BRANCH" --json number --jq '.[0].number // empty')" + fi + + if [ -z "$pr_number" ]; then + echo "Unable to resolve sync PR number." >&2 + exit 1 + fi + + gh pr edit "$pr_number" --repo "$GITHUB_REPOSITORY" --add-label fork-sync + if [ "${REBASE_OK}" = "true" ]; then + gh pr edit "$pr_number" --repo "$GITHUB_REPOSITORY" --remove-label sync-conflict 2>/dev/null || true + gh pr edit "$pr_number" --repo "$GITHUB_REPOSITORY" --remove-label janitor-needed 2>/dev/null || true + else + gh pr edit "$pr_number" --repo "$GITHUB_REPOSITORY" --add-label sync-conflict --add-label janitor-needed + gh pr comment "$pr_number" --repo "$GITHUB_REPOSITORY" --body "Automated sync hit conflicts while rebasing onto upstream/${UPSTREAM_BRANCH}. Janitor will attempt a repair pass." + fi + + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + + - name: Fail workflow when janitor is needed + if: steps.rebase.outputs.rebase_ok != 'true' + run: | + echo "Rebase conflicts detected. PR opened and labeled for janitor." >&2 + exit 1 diff --git a/.github/workflows/release-cli-fork.yml b/.github/workflows/release-cli-fork.yml new file mode 100644 index 000000000000..17752ce94bc5 --- /dev/null +++ b/.github/workflows/release-cli-fork.yml @@ -0,0 +1,80 @@ +name: release-cli-fork + +on: + push: + tags: + - "v*openwork*" + workflow_dispatch: + inputs: + tag: + description: "Tag to release (for example v1.1.60-openwork.2)" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: release-cli-fork-${{ github.ref }}-${{ github.event.inputs.tag || '' }} + cancel-in-progress: false + +jobs: + release-cli: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Resolve tag and version + id: meta + env: + INPUT_TAG: ${{ github.event.inputs.tag || '' }} + run: | + set -euo pipefail + tag="${INPUT_TAG}" + if [ -z "$tag" ]; then + tag="${GITHUB_REF_NAME}" + fi + + if [[ "$tag" != v* ]]; then + tag="v${tag}" + fi + + version="${tag#v}" + if [ -z "$version" ]; then + echo "Unable to resolve release version from tag: $tag" >&2 + exit 1 + fi + + git fetch --tags --prune + git checkout "$tag" + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Ensure GitHub release exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --notes "Fork CLI release for OpenWork." + + - name: Build and upload CLI assets + env: + OPENCODE_VERSION: ${{ steps.meta.outputs.version }} + OPENCODE_RELEASE: "1" + GH_TOKEN: ${{ github.token }} + run: ./packages/opencode/script/build.ts