Skip to content

Spec Sync

Spec Sync #138

Workflow file for this run

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