Repository navigation
Spec Sync #138
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Spec Sync | |
| on: | |
| schedule: | |
| - cron: '0 * * * *' # hourly; cron covers correctness, dispatch cuts latency | |
| workflow_dispatch: {} | |
| # Serialize runs so a cron tick and a manual dispatch can't race on the sync branch. | |
| concurrency: | |
| group: spec-sync | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read # all writes go through SPEC_SYNC_TOKEN, never GITHUB_TOKEN | |
| jobs: | |
| spec-sync: | |
| # Guard so the scheduled/dispatch job only runs on the canonical repo — forks lack the | |
| # secrets and would just produce noisy failing runs. | |
| if: github.repository == 'landing-ai/ade-python' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 # headroom for the AI wiring step (up to --max-turns 250) plus rye sync | |
| env: | |
| V1_SPEC_URL: https://api.va.staging.landing.ai/v1/ade/openapi.json # staging drives the loop | |
| SYNC_BRANCH: spec-sync/v1 # fixed branch: reruns update one PR in place, never a pile of them | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| # SPEC_SYNC_TOKEN is a fine-grained PAT (Contents: RW, Pull requests: RW) scoped to this | |
| # repo. It must NOT be the default GITHUB_TOKEN: pushes/PRs authored by GITHUB_TOKEN do | |
| # not trigger the gate workflows (anti-recursion). | |
| token: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| fetch-depth: 0 # tags needed for surface-lock baseline | |
| - name: Install Rye | |
| run: | | |
| curl -sSf https://rye.astral.sh/get | bash | |
| echo "$HOME/.rye/shims" >> "$GITHUB_PATH" | |
| env: | |
| RYE_VERSION: '0.44.0' | |
| RYE_INSTALL_OPTION: '--yes' | |
| - name: Install dependencies | |
| run: rye sync --all-features | |
| - name: Detect drift | |
| id: drift | |
| run: | | |
| set +e | |
| ./scripts/spec-sync/check-drift.sh "$V1_SPEC_URL" specs/v1-ade.json | |
| echo "code=$?" >> "$GITHUB_OUTPUT" | |
| - name: No drift | |
| if: steps.drift.outputs.code == '0' | |
| run: echo "specs in sync; nothing to do." | |
| - name: Fail on fetch error | |
| if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' | |
| run: | | |
| echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" | |
| exit 1 | |
| # If a sync PR is already open, do nothing: it's awaiting human review, and re-running | |
| # would duplicate gate runs and Claude API spend every hour until it merges. | |
| - name: Check for an open sync PR | |
| id: existing | |
| if: steps.drift.outputs.code == '10' | |
| env: | |
| GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| run: | | |
| if gh pr list --state open --head "$SYNC_BRANCH" --json number --jq '.[0].number' | grep -q .; then | |
| echo "open=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "open=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Sync PR already open — skip | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open == 'true' | |
| run: echo "A spec-sync PR is already open; skipping until it is merged or closed." | |
| # ---- Phase 1: mechanical (deterministic) ---- | |
| - name: Mechanical commit + push | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| run: | | |
| git config user.name "spec-sync[bot]" | |
| git config user.email "spec-sync@users.noreply.github.com" | |
| git checkout -B "$SYNC_BRANCH" | |
| ./scripts/spec-sync/gen-models.sh specs/v1-ade.json specs/_generated/v1_models.py | |
| git add specs/v1-ade.json specs/_generated/v1_models.py | |
| git commit -m "chore(spec-sync): update V1 spec snapshot + regenerated reference models" | |
| git push --force origin "$SYNC_BRANCH" | |
| # Open the PR now — before the AI step — so a run that dies in phase 2 leaves a visible PR | |
| # (with just the mechanical commit) instead of an orphan branch. | |
| - name: Open sync PR | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| run: | | |
| gh pr create --base main --head "$SYNC_BRANCH" \ | |
| --title "spec-sync: track V1 spec drift" \ | |
| --body $'Automated spec-sync PR.\n\n- **Commit 1 (mechanical):** normalized spec snapshot + regenerated reference models.\n- **Commit 2 (AI):** resources/methods/tests/docs wired from the spec diff (added after this PR opened).\n\nGates (surface-lock, contract tests, lint/test/typecheck) must pass. **Human review required before merge.**' | |
| # ---- Phase 2: AI wiring — edits ONLY. The agent holds no git/push capability, so a | |
| # prompt-injected spec description cannot route around review to push to a branch. ---- | |
| - name: AI wiring (edits only) | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| uses: anthropics/claude-code-action@v1 | |
| with: | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| github_token: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| prompt: | | |
| The previous commit updated specs/v1-ade.json and regenerated reference models in | |
| specs/_generated/v1_models.py. Wire the SDK to match the spec diff. | |
| First inspect the mechanical commit diff (`git diff HEAD~1..HEAD`) and read | |
| src/landingai_ade/resources/parse_jobs.py — mirror its structure exactly for any | |
| new resource (sync + async classes, the raw/streaming response wrappers, param | |
| TypedDicts under src/landingai_ade/types/, response models, and registration in | |
| src/landingai_ade/resources/__init__.py and the client). Add tests under | |
| tests/api_resources/ and update api.md and the README examples. Then run | |
| `./scripts/format` and `./scripts/lint` and fix anything they report. | |
| Rules: PURELY ADDITIVE — never modify or remove an existing public signature (the | |
| surface-lock CI job will fail the PR). `git diff` (read-only, to inspect the change) | |
| is fine; do NOT stage, commit, push, or run any other git command — a later workflow | |
| step commits and pushes your edits. | |
| claude_args: | | |
| --max-turns 250 | |
| --allowedTools "Edit,Write,Read,Glob,Grep,Bash(rye *),Bash(./scripts/*),Bash(git diff:*)" | |
| # Deterministic: commit whatever the AI edited and push to the sync branch. This guarantees | |
| # the AI changes actually reach the PR (the action's automation mode does not push), and it | |
| # is the only step with push capability. | |
| - name: Commit + push AI wiring | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| run: | | |
| git add -A | |
| if git diff --cached --quiet; then | |
| echo "AI step produced no changes." | |
| else | |
| git commit -m "feat(spec-sync): wire SDK to spec diff (AI)" | |
| git push origin HEAD:"$SYNC_BRANCH" | |
| fi | |
| # V2 loop. Same machinery as the V1 job above, pointed at the V2 spec on the AIDE gateway | |
| # and a separate sync branch, with a V2-tailored wiring prompt. Kept as a distinct job (not a | |
| # matrix) so the proven V1 loop is untouched and the two prompts can diverge freely. | |
| # | |
| # HOSTS (do not conflate): the V2 *spec* is published at aide.[env]/openapi.json; the V2 *API* | |
| # (what the SDK and contract tests call) is api.ade.[env]. We fetch drift from aide; the SDK | |
| # never talks to aide (its paths are staff-SSO-gated). | |
| spec-sync-v2: | |
| if: github.repository == 'landing-ai/ade-python' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 # headroom for the AI wiring step (up to --max-turns 250) plus rye sync | |
| env: | |
| V2_SPEC_URL: https://aide.staging.landing.ai/openapi.json # staging drives the loop | |
| SYNC_BRANCH: spec-sync/v2 # separate branch so V1 and V2 sync PRs never collide | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| # SPEC_SYNC_TOKEN (fine-grained PAT), NOT GITHUB_TOKEN: pushes/PRs authored by | |
| # GITHUB_TOKEN do not trigger the gate workflows (anti-recursion). | |
| token: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| fetch-depth: 0 # tags needed for surface-lock baseline | |
| - name: Install Rye | |
| run: | | |
| curl -sSf https://rye.astral.sh/get | bash | |
| echo "$HOME/.rye/shims" >> "$GITHUB_PATH" | |
| env: | |
| RYE_VERSION: '0.44.0' | |
| RYE_INSTALL_OPTION: '--yes' | |
| - name: Install dependencies | |
| run: rye sync --all-features | |
| - name: Detect drift | |
| id: drift | |
| run: | | |
| set +e | |
| ./scripts/spec-sync/check-drift.sh "$V2_SPEC_URL" specs/v2-aide.json | |
| echo "code=$?" >> "$GITHUB_OUTPUT" | |
| - name: No drift | |
| if: steps.drift.outputs.code == '0' | |
| run: echo "specs in sync; nothing to do." | |
| - name: Fail on fetch error | |
| if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' | |
| run: | | |
| echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" | |
| exit 1 | |
| - name: Check for an open sync PR | |
| id: existing | |
| if: steps.drift.outputs.code == '10' | |
| env: | |
| GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| run: | | |
| # Fail CLOSED: capture the query result separately so a transient gh/API failure aborts | |
| # the job (default bash is `set -eo pipefail`, but a failure inside an `if` condition is | |
| # exempt from set -e — which would silently become open=false and force-push over an | |
| # existing human-reviewed PR's branch). | |
| count="$(gh pr list --state open --head "$SYNC_BRANCH" --json number --jq 'length')" | |
| if [ "$count" -gt 0 ]; then | |
| echo "open=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "open=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Sync PR already open — skip | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open == 'true' | |
| run: echo "A spec-sync PR is already open; skipping until it is merged or closed." | |
| # ---- Phase 1: mechanical (deterministic) ---- | |
| - name: Mechanical commit + push | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| run: | | |
| git config user.name "spec-sync[bot]" | |
| git config user.email "spec-sync@users.noreply.github.com" | |
| git checkout -B "$SYNC_BRANCH" | |
| ./scripts/spec-sync/gen-models.sh specs/v2-aide.json specs/_generated/v2_models.py | |
| git add specs/v2-aide.json specs/_generated/v2_models.py | |
| git commit -m "chore(spec-sync): update V2 spec snapshot + regenerated reference models" | |
| git push --force origin "$SYNC_BRANCH" | |
| - name: Open sync PR | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| run: | | |
| gh pr create --base main --head "$SYNC_BRANCH" \ | |
| --title "spec-sync: track V2 spec drift" \ | |
| --body $'Automated V2 spec-sync PR (`client.v2`).\n\n- **Commit 1 (mechanical):** normalized V2 spec snapshot + regenerated reference models.\n- **Commit 2 (AI, only if the spec diff needs SDK changes):** client.v2 resources/methods/tests/docs wired from the diff, added after this PR opened. Workflow-only drift is excluded and an AI no-op is skipped, so some drifts produce a **mechanical-only PR with no second commit**.\n\nGates (surface-lock, V2 contract tests, lint/test/typecheck) must pass. When present, the AI commit is a **draft a human finishes** (the V2 ergonomic layer — unified Job, dual-host, schema coercion — is not in the spec). **Human review required before merge.**' | |
| # ---- Phase 2: AI wiring — edits ONLY (no git/push; a later step commits). ---- | |
| - name: AI wiring (edits only) | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| uses: anthropics/claude-code-action@v1 | |
| with: | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| github_token: ${{ secrets.SPEC_SYNC_TOKEN }} | |
| prompt: | | |
| The previous commit updated specs/v2-aide.json and regenerated reference models in | |
| specs/_generated/v2_models.py. Wire the client.v2 SDK to match the spec diff. This is | |
| the V2 surface (client.v2.*), NOT V1 — do not touch V1 resources. | |
| OUT OF SCOPE — do NOT implement: /v2/workflow, /v2/workflow/jobs, and | |
| /v2/workflow/jobs/{job_id}. That surface is intentionally deferred; skip it entirely | |
| even though it appears in the spec and reference models. If the spec diff touches only | |
| workflow, make no changes. | |
| First inspect the ENTIRE mechanical commit diff (`git diff HEAD~1..HEAD`) — the | |
| operation/path hunks AND changed entries under `components.schemas` — to find every | |
| change backing client.v2. A response-field addition often changes ONLY a component | |
| schema (e.g. /v2/parse references #/components/schemas/ParseResponse) and its generated | |
| model, not the operation hunk, so for every changed schema trace its `$ref` consumers to | |
| the non-workflow operations that use it. Cover: (a) newly added /v2 routes; (b) field or | |
| schema changes on existing operations, INCLUDING component-only changes; (c) changes to | |
| /v1/files, which backs client.v2.files (src/landingai_ade/resources/v2/files.py). | |
| Distinguish REQUEST fields — wire as a new optional keyword parameter on the existing | |
| method (see the additive-changes rule below) — from RESPONSE-model fields — add the | |
| field to the corresponding response model under src/landingai_ade/types/v2/. For a | |
| genuinely new route, read src/landingai_ade/resources/v2/extract.py and | |
| src/landingai_ade/resources/v2/parse.py and mirror their structure: | |
| - Add a resource module under src/landingai_ade/resources/v2/ with a sync run() | |
| (multipart or JSON per the spec; route a 504 through | |
| lib/v2_errors.raise_if_sync_timeout) and, for an async route, a *JobsResource with | |
| create / get / wait / list. | |
| - Register it on the V2Resource/AsyncV2Resource container in | |
| src/landingai_ade/resources/v2/v2.py exactly as parse/extract are wired (lazy | |
| cached_property + an explicit-signature top-level delegator). Do NOT re-export the | |
| sub-resource from src/landingai_ade/resources/v2/__init__.py — that file exports | |
| only V2Resource/AsyncV2Resource, matching the reference files. | |
| - Normalize any async job into the unified Job (src/landingai_ade/types/v2/job.py) by | |
| adding a normalize_* function in src/landingai_ade/resources/v2/_normalize.py, | |
| mirroring normalize_parse_job / normalize_extract_job (tolerant status via | |
| _status(); map the envelope's result / error / timestamps). | |
| - Put response models under src/landingai_ade/types/v2/. If a run accepts a JSON | |
| Schema, accept a pydantic model / dict / JSON string and coerce via | |
| lib/schema_utils.coerce_schema_to_dict. | |
| - Build request URLs with V2ResourceMixin._v2_url — dual-host routing is automatic; | |
| never hardcode a host. | |
| Add a live check to tests/contract/test_v2_smoke.py (marked `contract`) and unit tests | |
| under tests/api_resources/. Update api.md, and create or update docs/v2-testing.md | |
| (the V2 QA guide) with the new surface. Write clean, import-sorted code matching the | |
| style of the files you mirror — a later fixed step runs `./scripts/format`, and the PR's | |
| lint/test CI reports anything else for a human to resolve. | |
| You may only edit product code: src/, tests/, docs/, and api.md. Do NOT edit scripts/, | |
| .github/, specs/, or any dotfile. | |
| Additive-changes rule: BACKWARD-COMPATIBLE ADDITIONS ONLY. You MAY add a new optional | |
| keyword parameter (with a default) to an existing parse/extract method to wire an | |
| additive request field — surface-lock permits that. You must NOT remove or rename a | |
| public symbol or parameter, change a type, or make an existing parameter required; | |
| surface-lock will fail the PR on any of those. `git diff` (read-only, to inspect the | |
| change) is fine; do NOT stage, commit, push, or run any other git command — a later | |
| workflow step commits and pushes your edits. | |
| claude_args: | | |
| --max-turns 250 | |
| --allowedTools "Edit,Write,Read,Glob,Grep,Bash(git diff:*)" | |
| # The AI step has NO shell (only git diff), so it cannot itself execute anything with the | |
| # checkout's push credentials. But its Write tool is repo-wide, so before running the trusted | |
| # formatter — and before the `git add -A` below — we enforce the product-code ALLOWLIST: | |
| # revert every tracked edit and delete every untracked file OUTSIDE src/ tests/ docs/ api.md. | |
| # This stops an injected edit to e.g. pyproject.toml (which defines what `rye run format` | |
| # runs) from executing here, and stops any out-of-scope edit from reaching the commit. | |
| # (`git clean` has no -x, so gitignored paths like .venv are untouched.) | |
| - name: Restrict AI edits to product code, then format | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| run: | | |
| git checkout -- . ':(exclude)src' ':(exclude)tests' ':(exclude)docs' ':(exclude)api.md' | |
| git clean -fd -e /src -e /tests -e /docs | |
| ./scripts/format | |
| - name: Commit + push AI wiring | |
| if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' | |
| run: | | |
| git add -A | |
| if git diff --cached --quiet; then | |
| echo "AI step produced no changes." | |
| else | |
| git commit -m "feat(spec-sync): wire client.v2 to spec diff (AI)" | |
| git push origin HEAD:"$SYNC_BRANCH" | |
| fi |