From 1e3ae510ced9c174daf1170f97b6b0b6b719ac1a Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Thu, 30 Jul 2026 15:34:35 +0800 Subject: [PATCH 1/4] ci(spec-sync): read specs from live staging instead of S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the V1/V2 spec source from the S3 copy back to live staging (api.va.staging, aide.staging — both now public and fetchable without auth). Since staging auto-reclaims and must be booked, an unreachable spec URL is now the expected case: the fetch-error step skips (logs and exits 0) instead of failing, so the run opens no PR and sends no Slack alert. The failure alert now covers only AI-wiring and generic failures. Docs and comments updated. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spec-sync.yml | 57 ++++++++++++++-------------- CONTRIBUTING.md | 27 ++++++------- docs/design/stainless-exit-and-v2.md | 8 ++-- 3 files changed, 45 insertions(+), 47 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index e36efbb..58eeeb8 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -20,7 +20,7 @@ jobs: 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://ade-specs.s3.us-east-2.amazonaws.com/v1/staging/openapi.json # staging spec, published to S3 by vision-tools-rest-api CI + 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 SPEC_LABEL: V1 # used only in Slack/PR-comment copy to disambiguate the two jobs steps: @@ -54,11 +54,14 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - - name: Fail on fetch error + # A spec fetch/normalize failure is the EXPECTED case on staging, not an incident: staging + # auto-reclaims and must be booked to come back, so an unbooked cluster leaves the spec URL + # unreachable. Log it and end the run cleanly (no drift, no PR, no Slack) instead of failing — + # the next hourly run picks up any drift once staging is booked, and the release gate reads the + # always-online production spec, so releases are unaffected. + - name: Spec source unavailable — skip if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' - run: | - echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" - exit 1 + run: echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging is likely unbooked — skipping this run." # 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. @@ -216,9 +219,9 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. Tailors the message to the two failure modes worth - # calling out — a spec fetch/normalize error (S3 object missing/unreadable or its publisher failing) and an - # AI-wiring crash (PR left mechanical-only) — and falls back to a generic run-failed otherwise. + # Catch-all failure alert for this job. A spec fetch/normalize failure is handled earlier as an + # expected skip (staging is often unbooked) and never reaches here, so this only calls out an + # AI-wiring crash (PR left mechanical-only) and falls back to a generic run-failed otherwise. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -228,11 +231,9 @@ jobs: status: failure title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- - ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10') - && format('Spec fetch/normalize failed (exit {0}). spec-sync reads the V1 spec from , published by vision-tools-rest-api . Check the object is present and readable, and that the publisher last ran green.', steps.drift.outputs.code) - || (steps.ai_wiring.outcome == 'failure' + ${{ steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' - || 'spec-sync run failed. See the workflow run for details.') }} + || 'spec-sync run failed. See the workflow run for details.' }} · <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run> thread_ts: ${{ steps.root.outputs.ts }} @@ -240,16 +241,15 @@ jobs: # 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* originates in the AIDE gateway, but spec-sync reads it - # from the CI-published S3 copy (V2_SPEC_URL below) — not live from aide — so drift detection does - # not depend on staging being booked. The V2 *API* the SDK and contract tests call is api.ade.[env]; - # the SDK never talks to aide (its paths are staff-SSO-gated). + # 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://ade-specs.s3.us-east-2.amazonaws.com/v2/staging/openapi.json # staging spec, published to S3 by aide CI + 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 SPEC_LABEL: V2 # used only in Slack/PR-comment copy to disambiguate the two jobs steps: @@ -282,11 +282,14 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - - name: Fail on fetch error + # A spec fetch/normalize failure is the EXPECTED case on staging, not an incident: staging + # auto-reclaims and must be booked to come back, so an unbooked cluster leaves the spec URL + # unreachable. Log it and end the run cleanly (no drift, no PR, no Slack) instead of failing — + # the next hourly run picks up any drift once staging is booked, and the release gate reads the + # always-online production spec, so releases are unaffected. + - name: Spec source unavailable — skip if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' - run: | - echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" - exit 1 + run: echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging is likely unbooked — skipping this run." - name: Check for an open sync PR id: existing @@ -520,9 +523,9 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. Tailors the message to the two failure modes worth - # calling out — a spec fetch/normalize error (S3 object missing/unreadable or its publisher failing) and an - # AI-wiring crash (PR left mechanical-only) — and falls back to a generic run-failed otherwise. + # Catch-all failure alert for this job. A spec fetch/normalize failure is handled earlier as an + # expected skip (staging is often unbooked) and never reaches here, so this only calls out an + # AI-wiring crash (PR left mechanical-only) and falls back to a generic run-failed otherwise. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -532,10 +535,8 @@ jobs: status: failure title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- - ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10') - && format('Spec fetch/normalize failed (exit {0}). spec-sync reads the V2 spec from , published by aide . Check the object is present and readable, and that the publisher last ran green.', steps.drift.outputs.code) - || (steps.ai_wiring.outcome == 'failure' + ${{ steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' - || 'spec-sync run failed. See the workflow run for details.') }} + || 'spec-sync run failed. See the workflow run for details.' }} · <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run> thread_ts: ${{ steps.root.outputs.ts }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6449e53..f7a4f85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,25 +129,22 @@ the environment. ## Spec-sync pipeline -The SDK tracks the ADE OpenAPI spec automatically via `.github/workflows/spec-sync.yml` +The SDK tracks the live ADE OpenAPI spec automatically via `.github/workflows/spec-sync.yml` (hourly cron + manual `workflow_dispatch`). It is driven by the **staging** spec; releases gate on the **production** spec ("staging in, production out"). It runs **two independent loops** (one job each): the **V1** loop tracks the V1 spec against -`specs/v1-ade.json`, and the **V2** loop tracks the V2 spec against `specs/v2-aide.json` on a -separate `spec-sync/v2` branch. Both reuse the same scripts. - -Each loop reads the **staging** spec from a **CI-published copy on S3** -(`s3://ade-specs/{v1,v2}/staging/openapi.json`), generated by the upstream repos' -(`vision-tools-rest-api` / `aide`) CI — so spec-sync no longer depends on the staging cluster being -booked. The specs originate in the Vision Tools API and the AIDE gateway respectively; note the V2 -host split — the V2 *API* the SDK calls is `api.ade.[env]`, not the gateway. (The release gate still -live-fetches the **production** spec, which is always online.) - -On each run a loop fetches and normalizes its spec (`scripts/spec-sync/fetch-normalize.sh`) and -diffs it against its committed snapshot (`scripts/spec-sync/check-drift.sh`). On drift it opens one -PR with two attributed commits (paths shown for V1; the V2 loop uses the `v2-aide`/`v2_models` -equivalents): +`specs/v1-ade.json`, and the **V2** loop tracks the V2 spec on the AIDE gateway +(`aide.[env]/openapi.json`) against `specs/v2-aide.json` on a separate `spec-sync/v2` branch. Both +reuse the same scripts. (Note the host split: the V2 *spec* is published at `aide.[env]`, but the +V2 *API* the SDK calls is `api.ade.[env]`.) + +On each run a loop fetches and normalizes its live spec (`scripts/spec-sync/fetch-normalize.sh`) and +diffs it against its committed snapshot (`scripts/spec-sync/check-drift.sh`). Staging auto-reclaims +and must be booked, so a fetch failure (the spec URL unreachable) is treated as an expected no-op — +the run ends cleanly with no PR and no Slack alert, and the next run picks up drift once staging is +booked. On drift it opens one PR with two attributed commits (paths shown for V1; the V2 loop uses +the `v2-aide`/`v2_models` equivalents): 1. **Mechanical** — updated `specs/v1-ade.json` snapshot plus regenerated *reference* models in `specs/_generated/v1_models.py` (`scripts/spec-sync/gen-models.sh`, `datamodel-code-generator`). diff --git a/docs/design/stainless-exit-and-v2.md b/docs/design/stainless-exit-and-v2.md index b320123..29ce6bc 100644 --- a/docs/design/stainless-exit-and-v2.md +++ b/docs/design/stainless-exit-and-v2.md @@ -54,7 +54,7 @@ It *would* be the right call for a large, fast-moving generated surface or many ### The API contract -Derived from the aide gateway source (`services/gateway/`, `packages/aide_temporal/.../public_workflows/{parse,extract_v2}.py`, `customer_surface.py`). **The exposed `openapi.json` is the source of truth once public — this table must be re-verified against it** (see the aide ask below; the URL currently returns 401). +Derived from the aide gateway source (`services/gateway/`, `packages/aide_temporal/.../public_workflows/{parse,extract_v2}.py`, `customer_surface.py`). **The exposed `openapi.json` is the source of truth — this table must be re-verified against it** (the aide ask below is now DONE — the curated spec is served **unauthenticated**). Auth is unchanged from V1: `Authorization: Bearer `. Hosts differ from V1 (see environment matrix below). @@ -115,9 +115,9 @@ The V2 API lives on different hosts, so the existing 2-entry `environment` map b A new `LANDINGAI_ADE_ENVIRONMENT` env var selects the environment without code changes — that's the QA workflow. Explicit `base_url` / `v2_base_url` overrides (params and env vars) remain for mock servers and proxies; if only `base_url` is set, V2 traffic follows it too, so a mock captures everything. Two implementation subtleties are deferred to the PR, noted here only so they aren't rediscovered: routing is per-resource rather than by path prefix (`POST /v1/files` lives on the aide host), and V1 request paths are untouched, so existing behavior can't change. API keys stay per-environment, passed through as today. Open item: security sign-off on shipping `dev`/`staging` hostnames in public source (not secrets; fallback is env-var-only recognition). -### Required from aide (one change) +### Required from aide (one change) — DONE -Serve the curated, customer-surface-only OpenAPI spec unauthenticated at each environment's gateway host — `https://aide[.env].landing.ai/openapi.json` currently returns 401 (staff SSO). The curation hook (`install_job_openapi()`) already exists; verify staff routes are excluded and multipart bodies render correctly for codegen. This unblocks both the contract verification above and Problem 3. +**DONE (2026-07):** the curated, customer-surface-only OpenAPI spec is now served **unauthenticated** at each environment's gateway host (`https://aide[.env].landing.ai/openapi.json` — was 401/staff SSO). The curation hook (`install_job_openapi()`) excludes staff routes and renders multipart bodies for codegen. This unblocked both the contract verification above and Problem 3, and lets spec-sync live-fetch the V2 staging spec. --- @@ -164,7 +164,7 @@ Two consequences to be explicit about: - **The surface-lock baseline must be the last *release tag*, not the previous commit on `main`.** Merged-but-unreleased surface stays mutable: if a staging feature is reshaped or dropped before it ever reaches production, the next sync PR can amend or remove it without tripping the compat gate. Only *released* surface is locked — which is the actual promise made to users. - **A staging-only feature holds the release train**: nothing releases until it either reaches production or is reverted in staging (at which point the next sync PR removes it and unblocks). Self-correcting in both directions, and acceptable at our deploy cadence. -Verified: the V1 staging spec is already public (`https://api.va.staging.landing.ai/v1/ade/openapi.json` → 200); aide's staging spec is behind the same auth as production, so the single aide ask in Problem 2 (expose the curated spec per environment) covers this. +Verified: both staging specs are now public and fetchable **without auth** — V1 at `https://api.va.staging.landing.ai/v1/ade/openapi.json` and V2 at `https://aide.staging.landing.ai/openapi.json` (after the Problem 2 aide change landed). spec-sync live-fetches both. ### The AI step, concretely: `anthropics/claude-code-action` From 02bd73940a94cbc7f7c25884eb7a0afe075bc902 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Thu, 30 Jul 2026 16:13:36 +0800 Subject: [PATCH 2/4] fix(spec-sync): skip only an unreachable spec source, alert on invalid Address Copilot review. The previous skip treated every non-0/10 exit from check-drift.sh as an expected no-op, silently swallowing a reachable-but-empty/invalid spec, malformed JSON, or a script error. Give the unreachable-source case a dedicated exit 20 in fetch-normalize.sh (curl fetched separately from jq), and skip only that; every other failure now falls through to a new "Fail on spec error" step and fires the Slack alert. Also drop the incorrect note that the (unwired) release gate keeps releases unaffected. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spec-sync.yml | 72 ++++++++++++++++++---------- CONTRIBUTING.md | 8 ++-- scripts/spec-sync/check-drift.sh | 3 +- scripts/spec-sync/fetch-normalize.sh | 14 +++++- 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index 58eeeb8..b15e2b0 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -54,14 +54,22 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - # A spec fetch/normalize failure is the EXPECTED case on staging, not an incident: staging - # auto-reclaims and must be booked to come back, so an unbooked cluster leaves the spec URL - # unreachable. Log it and end the run cleanly (no drift, no PR, no Slack) instead of failing — - # the next hourly run picks up any drift once staging is booked, and the release gate reads the - # always-online production spec, so releases are unaffected. - - name: Spec source unavailable — skip - if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' - run: echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging is likely unbooked — skipping this run." + # An unreachable spec source (check-drift exit 20) is the EXPECTED case on staging, not an + # incident: staging auto-reclaims and must be booked to come back, so an unbooked cluster leaves + # the spec URL unreachable. Treat ONLY this as a no-op — log and end the run cleanly (no drift, + # no PR, no Slack); the next hourly run picks up any drift once staging is booked. Every other + # failure falls through to "Fail on spec error" below and alerts. + - name: Spec source unreachable — skip + if: steps.drift.outputs.code == '20' + run: echo "spec source unreachable (exit 20); staging is likely unbooked — skipping this run." + + # A reachable-but-invalid spec (empty/whitespace body, malformed JSON) or a script error is a + # real problem — fail loudly so the catch-all alert fires. Distinct from the exit-20 skip above. + - name: Fail on spec error + if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20' + run: | + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but the spec is empty/invalid or a script errored." + 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. @@ -219,9 +227,9 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. A spec fetch/normalize failure is handled earlier as an - # expected skip (staging is often unbooked) and never reaches here, so this only calls out an - # AI-wiring crash (PR left mechanical-only) and falls back to a generic run-failed otherwise. + # Catch-all failure alert for this job. An unreachable spec source (exit 20) is skipped earlier + # and never reaches here; what this calls out is a reachable-but-invalid spec / script error + # (exit not 0/10/20) and an AI-wiring crash (PR left mechanical-only), with a generic fallback. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -231,9 +239,11 @@ jobs: status: failure title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- - ${{ steps.ai_wiring.outcome == 'failure' + ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20') + && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but the spec is empty/invalid or a script errored (an unbooked cluster exits 20 and is skipped, not alerted). Investigate.', steps.drift.outputs.code) + || (steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' - || 'spec-sync run failed. See the workflow run for details.' }} + || 'spec-sync run failed. See the workflow run for details.') }} · <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run> thread_ts: ${{ steps.root.outputs.ts }} @@ -282,14 +292,22 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - # A spec fetch/normalize failure is the EXPECTED case on staging, not an incident: staging - # auto-reclaims and must be booked to come back, so an unbooked cluster leaves the spec URL - # unreachable. Log it and end the run cleanly (no drift, no PR, no Slack) instead of failing — - # the next hourly run picks up any drift once staging is booked, and the release gate reads the - # always-online production spec, so releases are unaffected. - - name: Spec source unavailable — skip - if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' - run: echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging is likely unbooked — skipping this run." + # An unreachable spec source (check-drift exit 20) is the EXPECTED case on staging, not an + # incident: staging auto-reclaims and must be booked to come back, so an unbooked cluster leaves + # the spec URL unreachable. Treat ONLY this as a no-op — log and end the run cleanly (no drift, + # no PR, no Slack); the next hourly run picks up any drift once staging is booked. Every other + # failure falls through to "Fail on spec error" below and alerts. + - name: Spec source unreachable — skip + if: steps.drift.outputs.code == '20' + run: echo "spec source unreachable (exit 20); staging is likely unbooked — skipping this run." + + # A reachable-but-invalid spec (empty/whitespace body, malformed JSON) or a script error is a + # real problem — fail loudly so the catch-all alert fires. Distinct from the exit-20 skip above. + - name: Fail on spec error + if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20' + run: | + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but the spec is empty/invalid or a script errored." + exit 1 - name: Check for an open sync PR id: existing @@ -523,9 +541,9 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. A spec fetch/normalize failure is handled earlier as an - # expected skip (staging is often unbooked) and never reaches here, so this only calls out an - # AI-wiring crash (PR left mechanical-only) and falls back to a generic run-failed otherwise. + # Catch-all failure alert for this job. An unreachable spec source (exit 20) is skipped earlier + # and never reaches here; what this calls out is a reachable-but-invalid spec / script error + # (exit not 0/10/20) and an AI-wiring crash (PR left mechanical-only), with a generic fallback. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -535,8 +553,10 @@ jobs: status: failure title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- - ${{ steps.ai_wiring.outcome == 'failure' + ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20') + && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but the spec is empty/invalid or a script errored (an unbooked cluster exits 20 and is skipped, not alerted). Investigate.', steps.drift.outputs.code) + || (steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' - || 'spec-sync run failed. See the workflow run for details.' }} + || 'spec-sync run failed. See the workflow run for details.') }} · <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run> thread_ts: ${{ steps.root.outputs.ts }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7a4f85..1b8f5cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,10 +141,10 @@ V2 *API* the SDK calls is `api.ade.[env]`.) On each run a loop fetches and normalizes its live spec (`scripts/spec-sync/fetch-normalize.sh`) and diffs it against its committed snapshot (`scripts/spec-sync/check-drift.sh`). Staging auto-reclaims -and must be booked, so a fetch failure (the spec URL unreachable) is treated as an expected no-op — -the run ends cleanly with no PR and no Slack alert, and the next run picks up drift once staging is -booked. On drift it opens one PR with two attributed commits (paths shown for V1; the V2 loop uses -the `v2-aide`/`v2_models` equivalents): +and must be booked, so an unreachable spec source is treated as an expected no-op — the run ends +cleanly with no PR and no Slack alert, and the next run picks up drift once staging is booked. A spec +that is reachable but empty or invalid still fails loudly and alerts. On drift it opens one PR with +two attributed commits (paths shown for V1; the V2 loop uses the `v2-aide`/`v2_models` equivalents): 1. **Mechanical** — updated `specs/v1-ade.json` snapshot plus regenerated *reference* models in `specs/_generated/v1_models.py` (`scripts/spec-sync/gen-models.sh`, `datamodel-code-generator`). diff --git a/scripts/spec-sync/check-drift.sh b/scripts/spec-sync/check-drift.sh index 1a788cf..a9f7910 100755 --- a/scripts/spec-sync/check-drift.sh +++ b/scripts/spec-sync/check-drift.sh @@ -2,7 +2,8 @@ # Compare a live spec against its committed snapshot. # exit 0 -> no drift # exit 10 -> drift detected; updated in place with the live spec -# other -> operational error (e.g. fetch failure) +# exit 20 -> spec source unreachable (e.g. unbooked staging); caller may treat as an expected no-op +# other -> operational error (reachable but empty/invalid spec, script error, etc.) set -euo pipefail if [ "$#" -ne 2 ]; then diff --git a/scripts/spec-sync/fetch-normalize.sh b/scripts/spec-sync/fetch-normalize.sh index 82213ce..e0f9d93 100755 --- a/scripts/spec-sync/fetch-normalize.sh +++ b/scripts/spec-sync/fetch-normalize.sh @@ -9,8 +9,20 @@ if [ "$#" -ne 1 ]; then fi url="$1" +# Fetch first, separately from normalization, so the two failure modes stay distinguishable. A fetch +# failure (source unreachable — e.g. an unbooked staging cluster) exits 20, which the caller treats +# as an expected no-op; a reachable-but-invalid spec still fails loudly below. +raw="$(curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url")" || exit 20 # `jq -S` sorts object keys only; array element order (e.g. `required`, `enum`, `tags`) is # preserved as emitted by the backend. This assumes the gateway emits arrays deterministically. # If it ever reorders them, drift detection would fire on cosmetic churn (phantom PRs) — start # debugging false drift here. -curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url" | jq -S . +# A 200 with an empty/whitespace body would make `jq` emit nothing and exit 0 — a silent "empty spec" +# that check-drift would treat as drift and commit, clobbering the snapshot. Reject it loudly instead. +# (Malformed JSON already fails via jq's non-zero exit under pipefail.) +normalized="$(printf '%s' "$raw" | jq -S .)" +if [ -z "$normalized" ]; then + echo "fetch-normalize: empty/blank spec from $url" >&2 + exit 1 +fi +printf '%s\n' "$normalized" From 14defe5439a4dbfabfeaf72148d4dca2a1636656 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Thu, 30 Jul 2026 16:31:32 +0800 Subject: [PATCH 3/4] ci: re-trigger PR Gates so surface-lock sees breaking-change-approved Co-Authored-By: Claude Opus 4.8 From 2a8bd8e245d1830b540b6e6dc3009edfd7b652e5 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Fri, 31 Jul 2026 11:12:58 +0800 Subject: [PATCH 4/4] fix(spec-sync): distinguish 404/unreachable (skip) from HTTP errors (alert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #136. `curl -f` collapsed every 4xx/5xx into a single exit code, so this mapped an auth regression (401/403) and a persistent 5xx server error to the same exit 20 as an unbooked cluster — skipping them silently. fetch-normalize.sh now inspects the HTTP status: a transport failure (DNS/refused/timeout) and a 404 (an unbooked staging cluster's response — see the book-ade flow) exit 20 and are treated as the expected no-op; any other non-200 (401/403/5xx), an empty/invalid spec, or a script error exits non-20 and fires the Slack alert. Workflow comments, check-drift.sh, and CONTRIBUTING.md updated to match. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spec-sync.yml | 52 +++++++++++++++------------- CONTRIBUTING.md | 7 ++-- scripts/spec-sync/check-drift.sh | 5 +-- scripts/spec-sync/fetch-normalize.sh | 28 +++++++++++---- 4 files changed, 56 insertions(+), 36 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index b15e2b0..cafd8ea 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -54,21 +54,22 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - # An unreachable spec source (check-drift exit 20) is the EXPECTED case on staging, not an - # incident: staging auto-reclaims and must be booked to come back, so an unbooked cluster leaves - # the spec URL unreachable. Treat ONLY this as a no-op — log and end the run cleanly (no drift, - # no PR, no Slack); the next hourly run picks up any drift once staging is booked. Every other - # failure falls through to "Fail on spec error" below and alerts. - - name: Spec source unreachable — skip + # An unavailable spec source (check-drift exit 20) is the EXPECTED case on staging, not an + # incident: staging auto-reclaims and must be booked to come back — an unbooked cluster serves a + # 404 (or the host stops answering entirely). Treat ONLY this as a no-op — log and end the run + # cleanly (no drift, no PR, no Slack); the next hourly run picks up any drift once staging is + # booked. Every other failure (a reachable source returning 401/403/5xx, an empty/invalid spec, + # or a script error) falls through to "Fail on spec error" below and alerts. + - name: Spec source unavailable — skip if: steps.drift.outputs.code == '20' - run: echo "spec source unreachable (exit 20); staging is likely unbooked — skipping this run." + run: echo "spec source unavailable (exit 20); staging is likely unbooked — skipping this run." # A reachable-but-invalid spec (empty/whitespace body, malformed JSON) or a script error is a # real problem — fail loudly so the catch-all alert fires. Distinct from the exit-20 skip above. - name: Fail on spec error if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20' run: | - echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but the spec is empty/invalid or a script errored." + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored." exit 1 # If a sync PR is already open, do nothing: it's awaiting human review, and re-running @@ -227,9 +228,10 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. An unreachable spec source (exit 20) is skipped earlier - # and never reaches here; what this calls out is a reachable-but-invalid spec / script error - # (exit not 0/10/20) and an AI-wiring crash (PR left mechanical-only), with a generic fallback. + # Catch-all failure alert for this job. An unavailable spec source (exit 20) is skipped earlier + # and never reaches here; what this calls out is a reachable source that errored — an HTTP error + # status / empty / invalid spec, or a script error (exit not 0/10/20) — and an AI-wiring crash + # (PR left mechanical-only), with a generic fallback. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -240,7 +242,7 @@ jobs: title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20') - && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but the spec is empty/invalid or a script errored (an unbooked cluster exits 20 and is skipped, not alerted). Investigate.', steps.drift.outputs.code) + && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored (an unbooked cluster 404s → exit 20, skipped, not alerted). Investigate.', steps.drift.outputs.code) || (steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' || 'spec-sync run failed. See the workflow run for details.') }} @@ -292,21 +294,22 @@ jobs: if: steps.drift.outputs.code == '0' run: echo "specs in sync; nothing to do." - # An unreachable spec source (check-drift exit 20) is the EXPECTED case on staging, not an - # incident: staging auto-reclaims and must be booked to come back, so an unbooked cluster leaves - # the spec URL unreachable. Treat ONLY this as a no-op — log and end the run cleanly (no drift, - # no PR, no Slack); the next hourly run picks up any drift once staging is booked. Every other - # failure falls through to "Fail on spec error" below and alerts. - - name: Spec source unreachable — skip + # An unavailable spec source (check-drift exit 20) is the EXPECTED case on staging, not an + # incident: staging auto-reclaims and must be booked to come back — an unbooked cluster serves a + # 404 (or the host stops answering entirely). Treat ONLY this as a no-op — log and end the run + # cleanly (no drift, no PR, no Slack); the next hourly run picks up any drift once staging is + # booked. Every other failure (a reachable source returning 401/403/5xx, an empty/invalid spec, + # or a script error) falls through to "Fail on spec error" below and alerts. + - name: Spec source unavailable — skip if: steps.drift.outputs.code == '20' - run: echo "spec source unreachable (exit 20); staging is likely unbooked — skipping this run." + run: echo "spec source unavailable (exit 20); staging is likely unbooked — skipping this run." # A reachable-but-invalid spec (empty/whitespace body, malformed JSON) or a script error is a # real problem — fail loudly so the catch-all alert fires. Distinct from the exit-20 skip above. - name: Fail on spec error if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20' run: | - echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but the spec is empty/invalid or a script errored." + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }}); staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored." exit 1 - name: Check for an open sync PR @@ -541,9 +544,10 @@ jobs: text: ${{ steps.ai_push.outputs.summary }} thread_ts: ${{ steps.root.outputs.ts }} - # Catch-all failure alert for this job. An unreachable spec source (exit 20) is skipped earlier - # and never reaches here; what this calls out is a reachable-but-invalid spec / script error - # (exit not 0/10/20) and an AI-wiring crash (PR left mechanical-only), with a generic fallback. + # Catch-all failure alert for this job. An unavailable spec source (exit 20) is skipped earlier + # and never reaches here; what this calls out is a reachable source that errored — an HTTP error + # status / empty / invalid spec, or a script error (exit not 0/10/20) — and an AI-wiring crash + # (PR left mechanical-only), with a generic fallback. - name: Slack — spec-sync failed if: failure() uses: ./.github/actions/slack-notify @@ -554,7 +558,7 @@ jobs: title: 'spec-sync ${{ env.SPEC_LABEL }}: run failed' text: >- ${{ (steps.drift.outputs.code != '' && steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' && steps.drift.outputs.code != '20') - && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but the spec is empty/invalid or a script errored (an unbooked cluster exits 20 and is skipped, not alerted). Investigate.', steps.drift.outputs.code) + && format('Spec fetch/normalize failed (exit {0}) — staging was reachable but returned an error status (401/403/5xx), an empty/invalid spec, or a script errored (an unbooked cluster 404s → exit 20, skipped, not alerted). Investigate.', steps.drift.outputs.code) || (steps.ai_wiring.outcome == 'failure' && 'AI wiring step failed. Any open sync PR has only the mechanical commit and needs manual wiring.' || 'spec-sync run failed. See the workflow run for details.') }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b8f5cb..508f7be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,9 +141,10 @@ V2 *API* the SDK calls is `api.ade.[env]`.) On each run a loop fetches and normalizes its live spec (`scripts/spec-sync/fetch-normalize.sh`) and diffs it against its committed snapshot (`scripts/spec-sync/check-drift.sh`). Staging auto-reclaims -and must be booked, so an unreachable spec source is treated as an expected no-op — the run ends -cleanly with no PR and no Slack alert, and the next run picks up drift once staging is booked. A spec -that is reachable but empty or invalid still fails loudly and alerts. On drift it opens one PR with +and must be booked, so an unavailable spec source (an unbooked cluster 404s, or the host stops +answering) is treated as an expected no-op — the run ends cleanly with no PR and no Slack alert, and +the next run picks up drift once staging is booked. A reachable source that returns an error status +(401/403/5xx) or an empty/invalid spec still fails loudly and alerts. On drift it opens one PR with two attributed commits (paths shown for V1; the V2 loop uses the `v2-aide`/`v2_models` equivalents): 1. **Mechanical** — updated `specs/v1-ade.json` snapshot plus regenerated *reference* models in diff --git a/scripts/spec-sync/check-drift.sh b/scripts/spec-sync/check-drift.sh index a9f7910..333abf2 100755 --- a/scripts/spec-sync/check-drift.sh +++ b/scripts/spec-sync/check-drift.sh @@ -2,8 +2,9 @@ # Compare a live spec against its committed snapshot. # exit 0 -> no drift # exit 10 -> drift detected; updated in place with the live spec -# exit 20 -> spec source unreachable (e.g. unbooked staging); caller may treat as an expected no-op -# other -> operational error (reachable but empty/invalid spec, script error, etc.) +# exit 20 -> spec source unavailable: unreachable (transport) or an unbooked staging 404; the +# caller may treat this as an expected no-op +# other -> operational error (reachable but returns an error status / empty / invalid spec, script error, etc.) set -euo pipefail if [ "$#" -ne 2 ]; then diff --git a/scripts/spec-sync/fetch-normalize.sh b/scripts/spec-sync/fetch-normalize.sh index 3826c47..80586b4 100755 --- a/scripts/spec-sync/fetch-normalize.sh +++ b/scripts/spec-sync/fetch-normalize.sh @@ -9,10 +9,24 @@ if [ "$#" -ne 1 ]; then fi url="$1" -# Fetch first, separately from normalization, so the two failure modes stay distinguishable. A fetch -# failure (source unreachable — e.g. an unbooked staging cluster) exits 20, which the caller treats -# as an expected no-op; a reachable-but-invalid spec still fails loudly below. -raw="$(curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url")" || exit 20 +body="$(mktemp)" +trap 'rm -f "$body"' EXIT +# Fetch WITHOUT `-f` so we can inspect the HTTP status ourselves and separate "expected on staging" +# from "real problem" — `-f` collapses every 4xx/5xx into one exit code. A transport failure (DNS, +# connection refused, timeout — curl's own non-zero exit) means the source is unreachable, which the +# caller treats as an expected no-op: exit 20. +code="$(curl -sS --max-time 30 --retry 3 --retry-delay 2 -o "$body" -w '%{http_code}' "$url")" || exit 20 +# Map the HTTP status: +# 200 -> normalize below. +# 404 -> an unbooked/torn-down staging cluster serves 404 (the spec route disappears with the +# backend); this is the EXPECTED no-op, so exit 20 as well. +# else -> a reachable source returning 401/403 (auth), 5xx (server error), etc. is a REAL problem; +# exit 1 (operational error) so the workflow's catch-all alert fires. +case "$code" in + 200) : ;; + 404) exit 20 ;; + *) echo "fetch-normalize: unexpected HTTP $code from $url" >&2; exit 1 ;; +esac # `jq -S` sorts object keys only; array element order (e.g. `required`, `enum`, `tags`) is # preserved as emitted by the backend. This assumes the gateway emits arrays deterministically. # If it ever reorders them, drift detection would fire on cosmetic churn (phantom PRs) — start @@ -26,13 +40,13 @@ raw="$(curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url")" || exit 20 # # A 200 with an empty/whitespace body would make `jq` emit nothing and exit 0 — a silent "empty spec" # that check-drift would treat as drift and commit, clobbering the snapshot. Reject it loudly instead. -# (Malformed JSON already fails via jq's non-zero exit under pipefail.) -normalized="$(printf '%s' "$raw" | jq -S ' +# (Malformed JSON already fails via jq's non-zero exit.) +normalized="$(jq -S ' del( .paths["/v2/extract/build-schema"], .paths["/v2/extract/build-schema/jobs"], .paths["/v2/extract/build-schema/jobs/{job_id}"] - )')" + )' < "$body")" if [ -z "$normalized" ]; then echo "fetch-normalize: empty/blank spec from $url" >&2 exit 1