diff --git a/.github/workflows/sync-vercel-detect-agent-upstream.yml b/.github/workflows/sync-vercel-detect-agent-upstream.yml new file mode 100644 index 0000000..ea6cc5a --- /dev/null +++ b/.github/workflows/sync-vercel-detect-agent-upstream.yml @@ -0,0 +1,205 @@ +# Sync Python port with upstream Vercel `packages/detect-agent` when it changes. +# +# Setup: +# - Repository secret: CURSOR_API_KEY (https://github.com/marketplace/actions/cursor-action) +# - No GitHub PAT: the final `gh` step uses the built-in GITHUB_TOKEN (see permissions below). +# - Uses actions/cache to remember the last synced upstream commit for that path. +# - First successful run bootstraps cache only (no agent); the next drift runs the agent + publish steps. +# - Cursor only edits files and runs checks; this workflow commits, pushes, and opens/updates the PR. +# +# Test on your PR (same repo only; fork PRs are ignored): +# 1. Create a repo label named `test-upstream-sync` (Settings -> Labels) if it does not exist. +# 2. Open your PR, add the `test-upstream-sync` label — this workflow runs once (labeled event). +# 3. PR runs use a separate Actions cache key so they do not overwrite the mainline upstream SHA. +# 4. Remove the label when done (optional). +# +# Manual run: Actions -> "Sync Vercel detect-agent upstream" -> Run workflow (pick your branch to test workflow_dispatch changes). + +name: Sync Vercel detect-agent upstream + +on: + schedule: + # Daily 14:05 UTC — adjust as you like + - cron: "5 14 * * *" + workflow_dispatch: + pull_request: + types: [labeled] + +concurrency: + group: sync-vercel-detect-agent-${{ github.ref }} + cancel-in-progress: false + +jobs: + sync: + if: >- + github.event_name != 'pull_request' || + (github.event_name == 'pull_request' && + github.event.label.name == 'test-upstream-sync' && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + env: + # runner.* is not allowed in job-level env; use stable paths (fresh VM per hosted job). + UPSTREAM_STATE_DIR: /tmp/detect-agent-upstream-state + UPSTREAM_VERCEL_MIRROR: /tmp/vercel-detect-agent-bare.git + UPSTREAM_DIFF_FILE: /tmp/vercel-packages-detect-agent.diff + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - uses: actions/cache@v4 + id: vercel_mirror_cache + with: + path: /tmp/vercel-detect-agent-bare.git + key: vercel-github-bare-mirror-v1-${{ runner.os }} + + - uses: actions/cache@v4 + id: upstream_state_cache + with: + path: /tmp/detect-agent-upstream-state + key: vercel-detect-agent-upstream-state-v1-${{ runner.os }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'default' }} + + - name: Compute upstream drift + id: drift + shell: bash + run: ./scripts/vercel_upstream_drift.sh + + - name: Prepare default branch for sync + if: steps.drift.outputs.should_sync == 'true' + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + db="${DEFAULT_BRANCH:-main}" + git fetch origin + git checkout "$db" + git pull --ff-only "origin/$db" + + - name: Configure git committer (actions bot) + if: steps.drift.outputs.should_sync == 'true' + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - uses: astral-sh/setup-uv@v5 + if: steps.drift.outputs.should_sync == 'true' + with: + enable-cache: true + python-version: "3.9" + + - name: Install Python deps (uv) + if: steps.drift.outputs.should_sync == 'true' + run: uv sync --extra dev + + - name: Run Cursor agent (port + verify) + id: cursor + if: steps.drift.outputs.should_sync == 'true' + uses: PunGrumpy/cursor-action@v1.0.0 + with: + api-key: ${{ secrets.CURSOR_API_KEY }} + model: composer-2 + working-directory: . + permissions: read-write + timeout: "3600" + prompt: | + You are updating this repository's Python port of Vercel's `packages/detect-agent` to match upstream changes. + + The workflow has already checked out the default branch and pulled latest; stay on the current branch. Do not run any `git` commands and do not use `gh`. + + Upstream facts: + - merge_base: ${{ steps.drift.outputs.merge_base }} + - last_recorded_upstream_tip: ${{ steps.drift.outputs.last_recorded }} + - new_upstream_tip: ${{ steps.drift.outputs.new_sha }} + - compare URL: ${{ steps.drift.outputs.compare_url }} + + Read the unified diff from this absolute path. Do not edit that path; it is CI scratch only: + ${{ steps.drift.outputs.diff_path }} + + Your job: + 1. Port upstream logic into `detect_agent/__init__.py` and `tests/` as needed. Mirror detection order and semantics from the TypeScript sources; keep typing style consistent with the existing Python file. + 2. Update `README.md` / `CHANGELOG.md` here if upstream docs/changelog warrant it. + 3. Run: `uv sync --extra dev && uv run pytest && uv run ruff check . && uv run ruff format --check .` + + If the diff file is empty but the SHAs differ, use the compare URL and current upstream `main` tree for that package to align the port. + + - name: Commit, push, and open or update PR + id: publish + if: steps.drift.outputs.should_sync == 'true' && steps.cursor.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + MERGE_BASE: ${{ steps.drift.outputs.merge_base }} + NEW_SHA: ${{ steps.drift.outputs.new_sha }} + COMPARE_URL: ${{ steps.drift.outputs.compare_url }} + BRANCH_SLUG: ${{ steps.drift.outputs.branch_slug }} + shell: bash + run: | + set -euo pipefail + db="${DEFAULT_BRANCH:-main}" + BRANCH_NAME="sync/vercel-detect-agent-${BRANCH_SLUG}" + + git branch -D "$BRANCH_NAME" 2>/dev/null || true + git checkout -b "$BRANCH_NAME" + + git add detect_agent tests README.md CHANGELOG.md pyproject.toml uv.lock 2>/dev/null || true + + if git diff --staged --quiet; then + echo "::notice::No staged changes after agent; skipping commit and PR." + exit 0 + fi + + short_mb="${MERGE_BASE:0:7}" + short_new="${NEW_SHA:0:7}" + git commit -m "sync: port vercel detect-agent ${short_mb}..${short_new}" + + git push -u origin "$BRANCH_NAME" --force + + EXISTING_PR="$(gh pr list --state open --head "$BRANCH_NAME" --json number --jq '.[0].number // empty' 2>/dev/null || true)" + + PR_TITLE="sync: port vercel detect-agent (${short_mb}..${short_new})" + PR_BODY="$(printf '%s\n\n%s\n\n%s\n' "Ports [\`packages/detect-agent\`](https://github.com/vercel/vercel/tree/main/packages/detect-agent) from [vercel/vercel](https://github.com/vercel/vercel)." "Compare: ${COMPARE_URL}" "cc @blainekasten")" + + if [ -n "${EXISTING_PR}" ]; then + gh pr edit "${EXISTING_PR}" --title "${PR_TITLE}" --body "${PR_BODY}" + gh pr edit "${EXISTING_PR}" --add-assignee blainekasten 2>/dev/null || true + echo "Updated existing PR #${EXISTING_PR}" + else + gh pr create \ + --title "${PR_TITLE}" \ + --body "${PR_BODY}" \ + --base "${db}" \ + --head "${BRANCH_NAME}" \ + --assignee blainekasten + fi + + - name: Record synced upstream SHA + if: success() && steps.drift.outputs.should_sync == 'true' && steps.cursor.outcome == 'success' && steps.publish.outcome == 'success' + shell: bash + env: + NEW_SHA: ${{ steps.drift.outputs.new_sha }} + run: | + set -euo pipefail + mkdir -p "${UPSTREAM_STATE_DIR}" + printf '%s\n' "${NEW_SHA}" >"${UPSTREAM_STATE_DIR}/last_packages_detect_agent_commit.txt" + + - name: Job summary + if: always() + shell: bash + run: | + set -euo pipefail + { + echo "## Upstream sync" + echo "- should_sync: ${{ steps.drift.outputs.should_sync }}" + echo "- new_sha: ${{ steps.drift.outputs.new_sha }}" + echo "- compare: ${{ steps.drift.outputs.compare_url }}" + } >>"${GITHUB_STEP_SUMMARY}" + if [[ "${{ steps.drift.outputs.should_sync }}" == "true" ]]; then + echo "- cursor exit: ${{ steps.cursor.outputs.exit-code }}" >>"${GITHUB_STEP_SUMMARY}" + echo "- publish outcome: ${{ steps.publish.outcome }}" >>"${GITHUB_STEP_SUMMARY}" + fi diff --git a/scripts/vercel_upstream_drift.sh b/scripts/vercel_upstream_drift.sh new file mode 100755 index 0000000..ecd9423 --- /dev/null +++ b/scripts/vercel_upstream_drift.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Compute whether vercel/vercel changed packages/detect-agent since the last run. +# Intended for GitHub Actions; optionally writes key=value lines to GITHUB_OUTPUT. +# +# Required env: +# UPSTREAM_STATE_DIR - directory holding last_packages_detect_agent_commit.txt +# UPSTREAM_VERCEL_MIRROR - path to (or parent of) bare clone of github.com/vercel/vercel.git +# UPSTREAM_DIFF_FILE - where to write unified diff when drift is detected +# +# Optional: +# GITHUB_OUTPUT - when set, writes should_sync, new_sha, merge_base, last_recorded, +# diff_path, compare_url (booleans as true/false strings) + +set -euo pipefail + +UPSTREAM_STATE_DIR="${UPSTREAM_STATE_DIR:?UPSTREAM_STATE_DIR is required}" +UPSTREAM_VERCEL_MIRROR="${UPSTREAM_VERCEL_MIRROR:?UPSTREAM_VERCEL_MIRROR is required}" +UPSTREAM_DIFF_FILE="${UPSTREAM_DIFF_FILE:?UPSTREAM_DIFF_FILE is required}" + +path='packages/detect-agent' +LAST_SHA_FILE="${UPSTREAM_STATE_DIR}/last_packages_detect_agent_commit.txt" + +append_kv() { + if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + return 0 + fi + printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT" +} + +mkdir -p "$UPSTREAM_STATE_DIR" + +if [[ ! -d "$UPSTREAM_VERCEL_MIRROR" ]]; then + git clone --mirror https://github.com/vercel/vercel.git "$UPSTREAM_VERCEL_MIRROR" +fi + +git -C "$UPSTREAM_VERCEL_MIRROR" fetch --prune origin + +# Mirror clones expose the default branch as refs/heads/main, not origin/main. +main_ref="main" +if ! git -C "$UPSTREAM_VERCEL_MIRROR" rev-parse -q --verify "${main_ref}^{commit}" >/dev/null 2>&1; then + main_ref="origin/main" +fi +if ! git -C "$UPSTREAM_VERCEL_MIRROR" rev-parse -q --verify "${main_ref}^{commit}" >/dev/null 2>&1; then + echo "failed to resolve main branch tip (tried main, origin/main) in ${UPSTREAM_VERCEL_MIRROR}" >&2 + exit 1 +fi + +NEW_SHA="$(git -C "$UPSTREAM_VERCEL_MIRROR" log -1 --format=%H "$main_ref" -- "$path")" +if [[ -z "$NEW_SHA" ]]; then + echo "failed to resolve latest commit for $path on ${main_ref}" >&2 + exit 1 +fi + +if [[ ! -f "$LAST_SHA_FILE" ]]; then + printf '%s\n' "$NEW_SHA" >"$LAST_SHA_FILE" + echo "bootstrapped upstream state (no sync): $NEW_SHA" >&2 + append_kv should_sync false + append_kv new_sha "$NEW_SHA" + exit 0 +fi + +LAST_SHA="$(tr -d '[:space:]' <"$LAST_SHA_FILE" || true)" +if [[ -z "$LAST_SHA" ]]; then + printf '%s\n' "$NEW_SHA" >"$LAST_SHA_FILE" + echo "repaired empty state file -> $NEW_SHA" >&2 + append_kv should_sync false + append_kv new_sha "$NEW_SHA" + exit 0 +fi + +if [[ "$LAST_SHA" == "$NEW_SHA" ]]; then + echo "no upstream drift: $NEW_SHA" >&2 + append_kv should_sync false + append_kv new_sha "$NEW_SHA" + exit 0 +fi + +echo "upstream drift: $LAST_SHA -> $NEW_SHA" >&2 + +if ! git -C "$UPSTREAM_VERCEL_MIRROR" cat-file -e "${LAST_SHA}^{commit}" 2>/dev/null; then + echo "widening fetch for stored sha" >&2 + git -C "$UPSTREAM_VERCEL_MIRROR" fetch --prune origin "${LAST_SHA}" || true +fi + +if ! git -C "$UPSTREAM_VERCEL_MIRROR" cat-file -e "${LAST_SHA}^{commit}" 2>/dev/null; then + echo "stored sha $LAST_SHA not found; delete ${LAST_SHA_FILE} to re-bootstrap" >&2 + exit 1 +fi + +MB="$(git -C "$UPSTREAM_VERCEL_MIRROR" merge-base "$LAST_SHA" "$NEW_SHA" 2>/dev/null || true)" +if [[ -z "$MB" ]]; then + MB="$LAST_SHA" +fi + +git -C "$UPSTREAM_VERCEL_MIRROR" diff "${MB}..${NEW_SHA}" -- "$path" >"$UPSTREAM_DIFF_FILE" || true + +COMPARE_URL="https://github.com/vercel/vercel/compare/${MB}...${NEW_SHA}" + +append_kv should_sync true +append_kv new_sha "$NEW_SHA" +append_kv branch_slug "${NEW_SHA:0:7}" +append_kv merge_base "$MB" +append_kv last_recorded "$LAST_SHA" +append_kv diff_path "$UPSTREAM_DIFF_FILE" +append_kv compare_url "$COMPARE_URL" + +exit 0