From bb9023d3337df7f1ea785c70e286a60a3e686243 Mon Sep 17 00:00:00 2001 From: Mingrui Zhang Date: Tue, 7 Jul 2026 11:58:03 -0700 Subject: [PATCH 1/6] chore: take over release automation and remove Stainless dependencies Stainless (acquired by Anthropic) sunsets its platform for our account on 2026-09-01. This implements Problem 1 of the attached design doc: - Add self-hosted release-please workflow (replaces the Stainless app, which opened release PRs and created tags/GitHub Releases externally) - Point release-please-config.json $schema at upstream googleapis - Simplify CI triggers to main + PRs; drop stainless-sdks runner conditionals and the pkg.stainless.com artifact upload - Remove .stats.yml (Stainless codegen metadata) - Rewrite CONTRIBUTING.md and SECURITY.md for owned, hand-maintained code - Drop "generated with Stainless" phrasing and the vscode.stainless.com MCP badge from README - Add the approved design doc (docs/design/stainless-exit-and-v2.md) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 42 +----- .github/workflows/release-please.yml | 30 ++++ .stats.yml | 4 - CONTRIBUTING.md | 11 +- README.md | 3 +- SECURITY.md | 15 +- docs/design/stainless-exit-and-v2.md | 204 +++++++++++++++++++++++++++ release-please-config.json | 2 +- scripts/utils/upload-artifact.sh | 27 ---- 9 files changed, 253 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/release-please.yml delete mode 100644 .stats.yml create mode 100644 docs/design/stainless-exit-and-v2.md delete mode 100755 scripts/utils/upload-artifact.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6645518..2279aba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,24 +2,14 @@ name: CI on: push: branches: - - '**' - - '!integrated/**' - - '!stl-preview-head/**' - - '!stl-preview-base/**' - - '!generated' - - '!codegen/**' - - 'codegen/stl/**' + - main pull_request: - branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' jobs: lint: timeout-minutes: 10 name: lint - runs-on: ${{ github.repository == 'stainless-sdks/ade-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -38,13 +28,9 @@ jobs: run: ./scripts/lint build: - if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 name: build - permissions: - contents: read - id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/ade-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -62,30 +48,10 @@ jobs: - name: Run build run: rye build - - name: Get GitHub OIDC Token - if: |- - github.repository == 'stainless-sdks/ade-python' && - !startsWith(github.ref, 'refs/heads/stl/') - id: github-oidc - uses: actions/github-script@v8 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Upload tarball - if: |- - github.repository == 'stainless-sdks/ade-python' && - !startsWith(github.ref, 'refs/heads/stl/') - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - run: ./scripts/utils/upload-artifact.sh - test: timeout-minutes: 10 name: test - runs-on: ${{ github.repository == 'stainless-sdks/ade-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..6ece53c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,30 @@ +# Self-hosted release automation, replacing the Stainless-run release-please +# after the Stainless platform sunset (2026-09-01). +# +# On every push to main this opens or updates a "release: x.y.z" PR from the +# existing release-please-config.json + .release-please-manifest.json. Merging +# that PR creates the git tag and the GitHub Release, which triggers +# .github/workflows/publish-pypi.yml. +# +# RELEASE_PLEASE_TOKEN must be a fine-grained PAT (or GitHub App installation +# token) with Contents: write and Pull requests: write on this repository. +# The default GITHUB_TOKEN cannot be used here: releases created with it do +# not trigger other workflows, so publishing would silently never run. +name: Release Please + +on: + push: + branches: + - main + +jobs: + release-please: + name: release please + runs-on: ubuntu-latest + if: github.repository == 'landing-ai/ade-python' + steps: + - uses: googleapis/release-please-action@v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.stats.yml b/.stats.yml deleted file mode 100644 index 852fb18..0000000 --- a/.stats.yml +++ /dev/null @@ -1,4 +0,0 @@ -configured_endpoints: 9 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/landingai%2Fade-027fb87ba705ac701f9632cddf16c55874cc5fa02d6cc0759a6a8f39583ff1b7.yml -openapi_spec_hash: 1b0a0f468129939b1f96fa130c1c60e2 -config_hash: d3c62c183c1d0f73f5d04504a415859b diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13dff52..8e72766 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,13 +34,16 @@ $ pip install -r requirements-dev.lock ## Modifying/Adding code -Most of the SDK is generated code. Modifications to code will be persisted between generations, but may -result in merge conflicts between manual patches and changes from the generator. The generator will never -modify the contents of the `src/landingai_ade/lib/` and `examples/` directories. +This SDK is fully owned and hand-maintained by LandingAI; it is no longer regenerated by Stainless. +All code in the repository can be edited directly, like any other codebase. + +Please use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages and PR +titles (`feat:`, `fix:`, `chore:`, …) — release automation (release-please) derives version bumps and +the changelog from them. ## Adding and running examples -All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. +Files in the `examples/` directory can be freely edited or added to. ```py # add an example to examples/.py diff --git a/README.md b/README.md index a62d9b7..6baa32b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ -A Python library for interacting with the **LandingAI Agentic Document Extraction REST API**, designed for flexibility, reliability, clarity, and performance. Built for Python 3.9+ and generated with [Stainless](https://www.stainless.com/). +A Python library for interacting with the **LandingAI Agentic Document Extraction REST API**, designed for flexibility, reliability, clarity, and performance. Built for Python 3.9+. ## ✨ Features @@ -40,7 +40,6 @@ A Python library for interacting with the **LandingAI Agentic Document Extractio Use the LandingAI ADE MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=landingai-ade-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImxhbmRpbmdhaS1hZGUtbWNwIl0sImVudiI6eyJWSVNJT05fQUdFTlRfQVBJX0tFWSI6Ik15IEFwaWtleSJ9fQ) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22landingai-ade-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22landingai-ade-mcp%22%5D%2C%22env%22%3A%7B%22VISION_AGENT_API_KEY%22%3A%22My%20Apikey%22%7D%7D) > Note: You may need to set environment variables in your MCP client. diff --git a/SECURITY.md b/SECURITY.md index be2fa6d..6b360d0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,25 +2,22 @@ ## Reporting Security Issues -This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. +This SDK is maintained by [LandingAI](https://landing.ai/). -To report a security issue, please contact the Stainless team at security@stainless.com. +To report a security issue, please use GitHub's private vulnerability reporting on this repository +("Security" → "Report a vulnerability"), or contact us at support@landing.ai. ## Responsible Disclosure We appreciate the efforts of security researchers and individuals who help us maintain the security of -SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +our SDKs. If you believe you have found a security vulnerability, please adhere to responsible disclosure practices by allowing us a reasonable amount of time to investigate and address the issue before making any information public. ## Reporting Non-SDK Related Security Issues -If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by LandingAI ADE, please follow the respective company's security reporting guidelines. - -### LandingAI ADE Terms and Policies - -Please contact support@landing.ai for any questions or concerns regarding the security of our services. +If you encounter security issues that are not directly related to this SDK but pertain to the services +or products provided by LandingAI, please contact support@landing.ai. --- diff --git a/docs/design/stainless-exit-and-v2.md b/docs/design/stainless-exit-and-v2.md new file mode 100644 index 0000000..6c9d3af --- /dev/null +++ b/docs/design/stainless-exit-and-v2.md @@ -0,0 +1,204 @@ +# ADE SDKs: Stainless exit, V2 support, and spec-sync automation + +- **Status**: Approved (2026-07-07) +- **Date**: 2026-07-06 +- **Scope**: [ade-python](https://github.com/landing-ai/ade-python), [ade-typescript](https://github.com/landing-ai/ade-typescript), one small change in `aide` +- **Context**: Stainless (acquired by Anthropic, 2026-05-18) sunsets its platform for our account on **2026-09-01**. + +This design covers three separable problems. They share a deadline and a theme, but each can be discussed, approved, and executed independently: + +1. **Getting off Stainless** — mostly cleanup; two concrete breaks to fix. +2. **V2 parse / V2 extract support** — the API contract and the SDK surface that wraps it. +3. **The new pipeline** — spec-sync automation that keeps both SDKs tracking both `openapi.json` specs. + +--- + +## Problem 1 — Getting off Stainless + +**The SDKs already work without Stainless, and will keep working.** Both repos are owned by `landing-ai`, the published packages on PyPI/npm have zero runtime dependency on Stainless, and the source builds are self-contained. No user is affected by the sunset. This problem is *not* a migration of code — it is a takeover of the maintenance machinery around the code. + +Exactly two things break, both verified: + +| Break | Evidence | Fix | +|---|---|---| +| **Releases stop.** Today's release pipeline is split: the Stainless GitHub App runs release-please *externally* — it opens the release PR, and on merge creates the git tag and GitHub Release. The in-repo `publish-pypi.yml` / `publish-npm.yml` workflows then trigger on the release event and run on GitHub runners with our registry tokens — that half is already Stainless-free and survives unchanged. | Every recent tag and GitHub Release in both repos was authored by `stainless-app[bot]`; no release-please workflow exists in-repo. Stale bot release PRs open now: [ade-python#90 "release: 1.13.0"](https://github.com/landing-ai/ade-python/pull/90), [ade-typescript#67 "release: 2.8.0"](https://github.com/landing-ai/ade-typescript/pull/67) (both since April). | Replace only the orchestration half: merge the pending release PRs, then add a self-hosted `release-please-action` workflow (~15 lines; the `release-please-config.json` already in each repo keeps working). Publish workflows and tokens need no change. | +| **The TS build fetches a Stainless-hosted artifact.** | `package.json` pins `"tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz"`. When the `stainless-api` org is archived/removed, `npm install` breaks. | Vendor the tarball, or repoint to a fork under `landing-ai`. | + +One pre-existing failure surfaced while verifying the above, and the takeover inherits it: **every `publish-npm` run since v2.5.0 (April 6) is red.** The root `landingai-ade` package publishes fine (npm is current at 2.7.0; PyPI runs are all green at 1.12.0), but a later step fails — **`landingai-ade-mcp` (the MCP server) has never reached npm**, and the unmerged `fix/npm-oidc-auth` branch suggests a known auth cause. Fix it (or explicitly descope MCP npm publishing) as part of this problem, so that after takeover a green publish run actually means everything shipped. + +Everything else is cleanup, no urgency beyond tidiness: + +- Remove `stainless-sdks/*` conditionals and the `pkg.stainless.com` upload step from both `ci.yml`s. +- Uninstall the Stainless GitHub App from the org. +- Rewrite `CONTRIBUTING.md` in both repos (currently says code is generated and directs changes to Stainless). +- Retire `.stats.yml` (superseded by Problem 3's committed spec snapshots). +- Bump the deprecated `actions/setup-node@v3` in `publish-npm.yml` (Node 20 action runtimes are removed from GitHub runners on 2026-09-16). +- Optional insurance: download the Stainless workspace bundle before the sunset. Not needed under this design (the V1 spec is public at `https://api.va.landing.ai/v1/ade/openapi.json`; `stainless.yml` only matters for their `stlc` tool), but it is free and the option expires 2026-09-01. + +**Exit criteria**: one release published end-to-end from each repo with no Stainless involvement. Estimated effort: 1–2 days total. + +### Why not `stlc` + +`stlc` is the exit path Stainless offers: a source-available CLI that keeps regenerating SDKs from the OpenAPI spec + `stainless.yml`, re-applying hand-written changes on top via tracking files. Its entire value is continuity — adopting it makes sense **iff we want to keep regenerating from `stainless.yml`**. We don't: + +- **The regeneration has nothing left to do.** The surface is small (12 V1 routes, 2 languages) and low-churn, and the backward-compatibility guarantee (Problem 2) means the generated V1 code should barely change again. The sync problem it solves is covered by Problem 3's pipeline, built from maintained, swappable OSS parts. +- **V2 would fight the tool.** Everything we plan to build — `client.v2.*`, job waiters, typed extraction — would live as "custom code" that stlc re-merges onto every regeneration; its own docs warn against restructuring generated output. +- **It is a dead end by design**: feature-frozen, unsupported (no SLA), source-available / internal-use-only, with onboarding help ending 2026-09-01. Adopting it makes our SDK toolchain unmaintained software on day 1 — a new platform dependency, which requirement 3 explicitly avoids. +- **It is heavier than what it replaces**: the recommended setup is a config repo + per-target private staging repos + seal-back PRs + two PATs — more standing infrastructure than the entire Problem 3 pipeline. + +It *would* be the right call for a large, fast-moving generated surface or many target languages; neither is planned. The bundle download above keeps this option open until 2026-09-01 — reversible until then, closed after. + +--- + +## Problem 2 — SDK support for V2 parse and V2 extract + +### 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). + +Auth is unchanged from V1: `Authorization: Bearer `. Hosts differ from V1 (see environment matrix below). + +**Parse** + +| Route | Notes | +|---|---| +| `POST /v2/parse` | Sync. multipart: `document` (file) *or* `document_url`; `model`; `options` (JSON-encoded ParseOptions). Returns `ParseResponse` inline + `X-Version` header. | +| `POST /v2/parse/jobs` | Async, 202. Same fields plus `priority` (`standard`\|`priority`), `output_save_url` (presigned URL for result delivery), `idempotency_key` (same key → same `job_id`). | +| `GET /v2/parse/jobs/{job_id}` | Poll. Envelope: `{job_id, status: pending|processing|completed|failed, started_at, progress, data: ParseResponse, output_url?, metadata, failure_reason?}`. | +| `GET /v2/parse/jobs` | List, paginated (`page`, `page_size`, `status`) → `{jobs, org_id, has_more}`. | + +**Extract** + +| Route | Notes | +|---|---| +| `POST /v2/extract` | Sync. JSON body: `schema` (JSON Schema, required); markdown from exactly one of `markdown` (inline) \| `markdown_ref` (from `POST /v1/files`) \| `markdown_url`; `model`; `options: {strict}`. Returns `{extraction, extraction_metadata (per-field {value, spans}), markdown, metadata {job_id, version, duration_ms, doc_id?, credit_usage}}`. **206** = partial success under strict mode. | +| `POST /v2/extract/jobs` | Async, 202. Plus `priority`, `idempotency_key`. | +| `GET /v2/extract/jobs/{job_id}` | Poll. Envelope: `{job_id, status, created_at, completed_at?, result?, error? {code, message}, progress?}`. | +| `GET /v2/extract/jobs` | List, paginated. | +| `POST /v1/files` | Staging endpoint for large markdown → `{file_ref}`; referenced via `markdown_ref`. | + +Contract items to settle with the aide team (cheap now, expensive after external adoption): + +1. **Envelope divergence**: parse jobs use `data`/`output_url`/`started_at`/`failure_reason`; extract jobs use `result`/`error`/`created_at`/`completed_at`. Recommend unifying before GA; otherwise SDKs paper over it forever. +2. `POST /v2/parse` with `password` returns 422 (encrypted PDFs unsupported) — confirm intended for launch. +3. `/v2/workflow` exists in the gateway but is **out of scope** here — confirm. +4. EU availability timing for V2 (`aide.eu-west-1.landing.ai` manifests exist). + +### The SDK surface + +Additive only — Python stays 1.x, TS stays 2.x, existing methods and types are untouched (V2 types live in their own `types/v2/` module and V1 types are never regenerated). V2 lands under a `v2` sub-client that shares the transport (auth, retries, http client) and carries its own base URL: + +```python +client = LandingAIADE(apikey=...) # prod, exactly as today +client = LandingAIADE(apikey=..., environment="staging") # QA + +client.parse(document=...) # unchanged → {v1}/v1/ade/parse +client.v2.parse(document=..., model=...) # new → {v2}/v2/parse +job = client.v2.parse_jobs.create(document=..., priority="priority") +res = client.v2.parse_jobs.wait(job.job_id, timeout=600) # polling helper +client.v2.extract(schema=InvoiceModel, markdown_url=...) # pydantic/zod schema accepted +client.v2.files.upload(...) # → file_ref for markdown_ref +``` + +Hand-written ergonomics on top of the generated types: `wait()` with backoff, pydantic/zod models accepted as `schema` (extending the existing `lib/schema_utils.py`), one `Job` shape presented to users regardless of the envelope question above, `idempotency_key` pass-through, `save_to` support on V2 methods for parity. + +### Multi-environment + +The V2 API lives on different hosts, so the existing 2-entry `environment` map becomes a 4-entry map of host *pairs*, and `environment` is the entire user-facing story — the client resolves both hosts from it and the `v2` sub-client routes to the right one internally: + +| `environment` | V1 base URL | V2 base URL | +|---|---|---| +| `production` (default) | `https://api.va.landing.ai` | `https://aide.landing.ai` | +| `eu` | `https://api.va.eu-west-1.landing.ai` | `https://aide.eu-west-1.landing.ai` | +| `staging` | `https://api.va.staging.landing.ai` | `https://aide.staging.landing.ai` | +| `dev` | `https://api.va.dev.landing.ai` | `https://aide.dev.landing.ai` | + +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) + +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. + +--- + +## Problem 3 — The pipeline: spec-sync automation + +Goal: no codegen/release platform, but the SDKs mechanically track **both** live specs — `https://api.va.landing.ai/v1/ade/openapi.json` (V1) and `https://aide.landing.ai/openapi.json` (V2). Drift is real today: the live V1 spec has 3 `/v1/ade/extract/jobs` routes that neither SDK implements. + +One workflow per SDK repo (`.github/workflows/spec-sync.yml`), triggered by cron (~6h) + manual dispatch (+ optional `repository_dispatch` from aide deploys later — cron covers correctness, dispatch only cuts latency): + +``` +fetch v1 + v2 openapi.json ─ normalize (jq -S) ─ diff vs committed specs/*.json + └─ on drift, ONE job, two phases, one PR branch: + commit 1 (mechanical): spec snapshot + regenerated types + (datamodel-code-generator / openapi-typescript) + commit 2 (AI): Claude Code GitHub Action wires resources, + methods, tests, docs from the spec diff + └─ PR gates (ordinary CI jobs): + surface-lock: api-extractor report (TS) / griffe check (Python) + → any change to RELEASED public surface fails mechanically + (baseline = last release tag; see spec-targeting below) + contract tests vs staging (uses Problem 2's environment support) + existing mock tests · lint · typecheck + └─ human review → merge → self-hosted release-please → publish on release +``` + +Design notes: + +- **One job, two phases**: the AI step needs the freshly generated types in its working tree, and a codegen failure must abort before the AI runs. Two separately attributed commits give reviewers an audit boundary — skim the mechanical commit, scrutinize the AI commit. +- **Committed snapshots** (`specs/v1-ade.json`, `specs/v2-aide.json`) make every API change visible in the PR diff. This replaces `.stats.yml`. +- **Per-repo and independent**: each SDK repo carries its own copy (same shape, different codegen tool). aide only serves the spec; no tokens flow upstream. +- The surface-lock gate is what makes the AI step safe to run unattended: the backward-compatibility guarantee from Problem 2 is enforced by CI, not by convention or review vigilance. + +### Which environment's spec drives the loop: staging in, production out + +The automation targets the **staging** spec; releases gate on the **production** spec. This decouples "SDK code is merged" from "SDK artifact is released", which is exactly the QA window we want: + +1. API deploys to staging → staging spec changes → sync PR opens (or refreshes its branch). +2. Automated gates run against staging (contract tests, surface-lock) → review → merge to `main`. +3. QA tests merged `main` against staging (`pip install git+…@main` + `LANDINGAI_ADE_ENVIRONMENT=staging`) while the artifact is *not yet published*. +4. API deploys to production → the release gate (every SDK-implemented path exists in the production spec) passes → release-please PR merges → PyPI/npm artifact ships. + +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. + +### The AI step, concretely: `anthropics/claude-code-action` + +This is the tool the Stainless transition guide itself names for post-Stainless SDK maintenance. What it is: an MIT-licensed, Anthropic-maintained GitHub Action ([repo](https://github.com/anthropics/claude-code-action), [docs](https://code.claude.com/docs/en/github-actions)) that runs the Claude Code agent headlessly on our own runner. v1 has been GA since Aug 2025. It has two auto-detected modes — interactive (responds to `@claude` mentions on PRs/issues) and automation (executes a `prompt` input non-interactively). The pipeline uses **automation mode only**: the step runs inside the spec-sync job after the mechanical phase, on the same checkout/branch, edits files per the prompt, and commits — producing phase-2's separately attributed commit. + +Operational facts that shape the workflow: + +- **Auth/cost**: our Anthropic API key as a repo secret (`anthropic_api_key`), or OIDC to Bedrock/Vertex if preferred. Code never leaves the runner except calls to the model API. Cost is per-run model usage; `--max-turns` caps it. +- **Guardrails are inputs, not hopes**: `claude_args: --allowedTools ...` restricts what it can execute; the prompt states the contract ("never modify existing public surface — CI will fail you"); it cannot merge; branch protection and human review apply as with any PR. +- **Token gotcha**: commits pushed with the default `GITHUB_TOKEN` do **not** trigger other workflows (GitHub's anti-recursion rule) — so the gate jobs would never run on the sync PR. The job must push with a GitHub App installation token or fine-grained PAT (`contents: write`, `pull-requests: write` on the SDK repo). Same pattern for the mechanical phase's push. +- **No lock-in**: the step is a thin wrapper over a headless agent CLI. If we ever prefer a different agent, the pipeline shape (mechanical commit → agent commit → gates) is agent-agnostic. + +Illustrative step (pin `@v1`; final flags at implementation time): + +```yaml +- uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ steps.app-token.outputs.token }} + prompt: | + The previous commit updated specs/*.json and the generated types to match. + Update resource classes, method signatures, tests, api.md and README + examples accordingly. Rules: purely additive — never modify existing + public signatures (the surface-lock CI job will fail the PR); follow + existing code conventions; commit to the current branch. + claude_args: | + --max-turns 30 + --allowedTools "Edit,Write,Read,Bash(git *),Bash(./scripts/*)" +``` + +**Proving the loop**: stand it up in ade-python first and let its inaugural PR be the known V1 gap (`extract/jobs` ×3). [PR #93](https://github.com/landing-ai/ade-python/pull/93) already hand-starts this — reconcile rather than duplicate. This validates the pipeline end-to-end on a real change before any V2 code exists, then port the workflow to ade-typescript. + +--- + +## Suggested order & ownership + +Problem 1 (July, ~2 days) → Problem 3 proven on V1 (July) → Problem 2 once aide exposes the spec (Aug), with the pipeline then extending to V2 automatically. Open: who owns sync-PR review and release cadence; staging API keys for CI (which vault); `/v2/workflow` scope confirmation. diff --git a/release-please-config.json b/release-please-config.json index c26f650..c957813 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -2,7 +2,7 @@ "packages": { ".": {} }, - "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "include-v-in-tag": true, "include-component-in-tag": false, "versioning": "prerelease", diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh deleted file mode 100755 index 9be4359..0000000 --- a/scripts/utils/upload-artifact.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -exuo pipefail - -FILENAME=$(basename dist/*.whl) - -RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ - -H "Authorization: Bearer $AUTH" \ - -H "Content-Type: application/json") - -SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') - -if [[ "$SIGNED_URL" == "null" ]]; then - echo -e "\033[31mFailed to get signed URL.\033[0m" - exit 1 -fi - -UPLOAD_RESPONSE=$(curl -v -X PUT \ - -H "Content-Type: binary/octet-stream" \ - --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) - -if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then - echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/ade-python/$SHA/$FILENAME'\033[0m" -else - echo -e "\033[31mFailed to upload artifact.\033[0m" - exit 1 -fi From c24f61f4120d82d4e0da6d1e054fc6bec252a067 Mon Sep 17 00:00:00 2001 From: Mingrui Zhang Date: Tue, 7 Jul 2026 14:53:32 -0700 Subject: [PATCH 2/6] chore: make releases dispatch-only with maintainer-chosen version bump Per review: ordinary merges to main must never create release PRs. Releases now start with a maintainer running the Release workflow and choosing patch/minor/major; the release-please CLI opens a release PR pinned to exactly that version (changelog and version stamping still come from the existing config/manifest). Merging that PR triggers the finalize job, which creates the tag + GitHub Release and thereby the PyPI publish. Config drops the now-bypassed prerelease versioning keys. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-please.yml | 30 ---------- .github/workflows/release.yml | 90 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 7 ++- docs/design/stainless-exit-and-v2.md | 6 +- release-please-config.json | 4 -- 5 files changed, 98 insertions(+), 39 deletions(-) delete mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 6ece53c..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Self-hosted release automation, replacing the Stainless-run release-please -# after the Stainless platform sunset (2026-09-01). -# -# On every push to main this opens or updates a "release: x.y.z" PR from the -# existing release-please-config.json + .release-please-manifest.json. Merging -# that PR creates the git tag and the GitHub Release, which triggers -# .github/workflows/publish-pypi.yml. -# -# RELEASE_PLEASE_TOKEN must be a fine-grained PAT (or GitHub App installation -# token) with Contents: write and Pull requests: write on this repository. -# The default GITHUB_TOKEN cannot be used here: releases created with it do -# not trigger other workflows, so publishing would silently never run. -name: Release Please - -on: - push: - branches: - - main - -jobs: - release-please: - name: release please - runs-on: ubuntu-latest - if: github.repository == 'landing-ai/ade-python' - steps: - - uses: googleapis/release-please-action@v5 - with: - token: ${{ secrets.RELEASE_PLEASE_TOKEN }} - config-file: release-please-config.json - manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6edb8bb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +# Manual, dispatch-only release automation (replaces the Stainless-run +# release-please after the platform sunset on 2026-09-01). +# +# How a release works: +# 1. A maintainer runs this workflow (Actions -> Release -> Run workflow) +# and picks the bump: patch / minor / major. +# 2. The open-release-pr job computes the next version from +# .release-please-manifest.json and opens (or updates) a +# "release: x.y.z" PR pinned to exactly that version, with a changelog +# generated from the commits since the last release tag. Nothing is +# released at this point. +# 3. When that release PR is merged, the finalize job creates the git tag +# and the GitHub Release, which triggers publish-pypi.yml. +# +# Ordinary PR merges never create release PRs or releases — releases happen +# only when a maintainer dispatches this workflow and merges the PR it opens. +# +# RELEASE_PLEASE_TOKEN must be a fine-grained PAT (or GitHub App installation +# token) with Contents: write and Pull requests: write on this repository. +# The default GITHUB_TOKEN cannot be used: PRs and releases it creates do not +# trigger other workflows, so CI would not run on the release PR and +# publishing would silently never run. +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version bump for this release' + type: choice + required: true + options: + - patch + - minor + - major + pull_request: + types: [closed] + +jobs: + open-release-pr: + name: open release PR + if: github.event_name == 'workflow_dispatch' && github.repository == 'landing-ai/ade-python' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Compute next version + id: version + env: + BUMP: ${{ inputs.bump }} + run: | + current="$(jq -r '.["."]' .release-please-manifest.json)" + IFS='.' read -r major minor patch <<< "$current" + case "$BUMP" in + major) next="$((major + 1)).0.0" ;; + minor) next="${major}.$((minor + 1)).0" ;; + patch) next="${major}.${minor}.$((patch + 1))" ;; + esac + echo "bumping $current -> $next ($BUMP)" + echo "version=$next" >> "$GITHUB_OUTPUT" + + - name: Open or update the release PR + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + run: | + npx --yes release-please@17.10.2 release-pr \ + --token="$GITHUB_TOKEN" \ + --repo-url="${{ github.repository }}" \ + --target-branch=main \ + --release-as="${{ steps.version.outputs.version }}" + + finalize: + name: tag + GitHub release + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release-please--') && + github.repository == 'landing-ai/ade-python' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create tag and GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + run: | + npx --yes release-please@17.10.2 github-release \ + --token="$GITHUB_TOKEN" \ + --repo-url="${{ github.repository }}" \ + --target-branch=main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e72766..f4119d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,8 +38,11 @@ This SDK is fully owned and hand-maintained by LandingAI; it is no longer regene All code in the repository can be edited directly, like any other codebase. Please use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages and PR -titles (`feat:`, `fix:`, `chore:`, …) — release automation (release-please) derives version bumps and -the changelog from them. +titles (`feat:`, `fix:`, `chore:`, …) — the release changelog is grouped by these prefixes. + +Releases are cut manually: a maintainer runs the **Release** workflow (Actions → Release → Run +workflow), chooses the version bump (patch / minor / major), and merges the release PR it opens. +Ordinary PR merges never trigger a release. ## Adding and running examples diff --git a/docs/design/stainless-exit-and-v2.md b/docs/design/stainless-exit-and-v2.md index 6c9d3af..76ad759 100644 --- a/docs/design/stainless-exit-and-v2.md +++ b/docs/design/stainless-exit-and-v2.md @@ -21,7 +21,7 @@ Exactly two things break, both verified: | Break | Evidence | Fix | |---|---|---| -| **Releases stop.** Today's release pipeline is split: the Stainless GitHub App runs release-please *externally* — it opens the release PR, and on merge creates the git tag and GitHub Release. The in-repo `publish-pypi.yml` / `publish-npm.yml` workflows then trigger on the release event and run on GitHub runners with our registry tokens — that half is already Stainless-free and survives unchanged. | Every recent tag and GitHub Release in both repos was authored by `stainless-app[bot]`; no release-please workflow exists in-repo. Stale bot release PRs open now: [ade-python#90 "release: 1.13.0"](https://github.com/landing-ai/ade-python/pull/90), [ade-typescript#67 "release: 2.8.0"](https://github.com/landing-ai/ade-typescript/pull/67) (both since April). | Replace only the orchestration half: merge the pending release PRs, then add a self-hosted `release-please-action` workflow (~15 lines; the `release-please-config.json` already in each repo keeps working). Publish workflows and tokens need no change. | +| **Releases stop.** Today's release pipeline is split: the Stainless GitHub App runs release-please *externally* — it opens the release PR, and on merge creates the git tag and GitHub Release. The in-repo `publish-pypi.yml` / `publish-npm.yml` workflows then trigger on the release event and run on GitHub runners with our registry tokens — that half is already Stainless-free and survives unchanged. | Every recent tag and GitHub Release in both repos was authored by `stainless-app[bot]`; no release-please workflow exists in-repo. Stale bot release PRs open now: [ade-python#90 "release: 1.13.0"](https://github.com/landing-ai/ade-python/pull/90), [ade-typescript#67 "release: 2.8.0"](https://github.com/landing-ai/ade-typescript/pull/67) (both since April). | Replace only the orchestration half: close the stale bot release PRs and add a dispatch-only **Release** workflow — a maintainer picks patch/minor/major and the release-please CLI opens a release PR pinned to that version (the `release-please-config.json` already in each repo keeps doing changelog + version stamping). Publish workflows and tokens need no change. | | **The TS build fetches a Stainless-hosted artifact.** | `package.json` pins `"tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz"`. When the `stainless-api` org is archived/removed, `npm install` breaks. | Vendor the tarball, or repoint to a fork under `landing-ai`. | One pre-existing failure surfaced while verifying the above, and the takeover inherits it: **every `publish-npm` run since v2.5.0 (April 6) is red.** The root `landingai-ade` package publishes fine (npm is current at 2.7.0; PyPI runs are all green at 1.12.0), but a later step fails — **`landingai-ade-mcp` (the MCP server) has never reached npm**, and the unmerged `fix/npm-oidc-auth` branch suggests a known auth cause. Fix it (or explicitly descope MCP npm publishing) as part of this problem, so that after takeover a green publish run actually means everything shipped. @@ -140,7 +140,7 @@ fetch v1 + v2 openapi.json ─ normalize (jq -S) ─ diff vs committed specs/*.j (baseline = last release tag; see spec-targeting below) contract tests vs staging (uses Problem 2's environment support) existing mock tests · lint · typecheck - └─ human review → merge → self-hosted release-please → publish on release + └─ human review → merge → (maintainer dispatches Release, picks bump) → publish ``` Design notes: @@ -157,7 +157,7 @@ The automation targets the **staging** spec; releases gate on the **production** 1. API deploys to staging → staging spec changes → sync PR opens (or refreshes its branch). 2. Automated gates run against staging (contract tests, surface-lock) → review → merge to `main`. 3. QA tests merged `main` against staging (`pip install git+…@main` + `LANDINGAI_ADE_ENVIRONMENT=staging`) while the artifact is *not yet published*. -4. API deploys to production → the release gate (every SDK-implemented path exists in the production spec) passes → release-please PR merges → PyPI/npm artifact ships. +4. API deploys to production → the release gate (every SDK-implemented path exists in the production spec) passes → a maintainer dispatches the Release workflow (choosing the bump) and merges its release PR → PyPI/npm artifact ships. Two consequences to be explicit about: diff --git a/release-please-config.json b/release-please-config.json index c957813..31caba6 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,10 +5,6 @@ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "include-v-in-tag": true, "include-component-in-tag": false, - "versioning": "prerelease", - "prerelease": true, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false, "pull-request-header": "Automated Release PR", "pull-request-title-pattern": "release: ${version}", "changelog-sections": [ From 1a3245c5e36971a0d03ddca15ce05d69b296e524 Mon Sep 17 00:00:00 2001 From: Mingrui Zhang Date: Tue, 7 Jul 2026 15:06:56 -0700 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20release=20directly=20from=20dispat?= =?UTF-8?q?ch=20=E2=80=94=20no=20release=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: dispatching the Release workflow IS the release decision, so the intermediate release PR is gone. The workflow now stamps the version (pyproject.toml, _version.py), prepends the changelog section, lands the release commit directly on main (requires the RELEASE_TOKEN owner to have ruleset bypass), tags it, and creates the GitHub Release that triggers the PyPI publish. release-please is fully removed (config, manifest, version annotations) in favor of a small owned script. Co-Authored-By: Claude Fable 5 --- .github/scripts/generate_release_notes.py | 74 +++++++++++++++ .github/workflows/release.yml | 104 ++++++++++++---------- .release-please-manifest.json | 3 - CONTRIBUTING.md | 5 +- docs/design/stainless-exit-and-v2.md | 4 +- release-please-config.json | 62 ------------- src/landingai_ade/_version.py | 2 +- 7 files changed, 137 insertions(+), 117 deletions(-) create mode 100644 .github/scripts/generate_release_notes.py delete mode 100644 .release-please-manifest.json delete mode 100644 release-please-config.json diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py new file mode 100644 index 0000000..2e25979 --- /dev/null +++ b/.github/scripts/generate_release_notes.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Generate a markdown changelog section for the commits since the previous tag. + +Usage: generate_release_notes.py + +Reads `git log ..HEAD` and groups Conventional Commit subjects into +sections (same section layout the repo's CHANGELOG.md has always used). The +Release workflow prepends the output to CHANGELOG.md and uses it as the GitHub +Release notes. +""" + +import re +import subprocess +import sys +from datetime import date + +SECTIONS = [ + ("feat", "Features"), + ("fix", "Bug Fixes"), + ("perf", "Performance Improvements"), + ("revert", "Reverts"), + ("chore", "Chores"), + ("docs", "Documentation"), + ("style", "Styles"), + ("refactor", "Refactors"), + ("build", "Build System"), + ("other", "Other Changes"), +] +HIDDEN = {"test", "ci", "release"} +CONVENTIONAL = re.compile(r"^(?P[a-z]+)(?:\((?P[^)]*)\))?!?:\s*(?P.+)$") + + +def main() -> None: + repo, prev_tag, next_version = sys.argv[1:4] + log = subprocess.run( + ["git", "log", "--no-merges", "--pretty=format:%h\t%H\t%s", f"{prev_tag}..HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout + + buckets = {} + for line in log.splitlines(): + if not line.strip(): + continue + short, full, subject = line.split("\t", 2) + match = CONVENTIONAL.match(subject) + if match: + kind = match.group("type") + if kind in HIDDEN: + continue + if kind not in {key for key, _ in SECTIONS}: + kind = "other" + scope, desc = match.group("scope"), match.group("desc") + text = f"**{scope}:** {desc}" if scope else desc + else: + kind, text = "other", subject + entry = f"* {text} ([{short}](https://github.com/{repo}/commit/{full}))" + buckets.setdefault(kind, []).append(entry) + + out = [ + f"## {next_version} ({date.today().isoformat()})", + "", + f"Full Changelog: [{prev_tag}...v{next_version}](https://github.com/{repo}/compare/{prev_tag}...v{next_version})", + ] + for kind, title in SECTIONS: + entries = buckets.get(kind, []) + if entries: + out += ["", f"### {title}", ""] + entries + print("\n".join(out)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6edb8bb..12780a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,27 @@ # Manual, dispatch-only release automation (replaces the Stainless-run # release-please after the platform sunset on 2026-09-01). # -# How a release works: -# 1. A maintainer runs this workflow (Actions -> Release -> Run workflow) -# and picks the bump: patch / minor / major. -# 2. The open-release-pr job computes the next version from -# .release-please-manifest.json and opens (or updates) a -# "release: x.y.z" PR pinned to exactly that version, with a changelog -# generated from the commits since the last release tag. Nothing is -# released at this point. -# 3. When that release PR is merged, the finalize job creates the git tag -# and the GitHub Release, which triggers publish-pypi.yml. +# A maintainer runs this workflow (Actions -> Release -> Run workflow) and +# picks the bump: patch / minor / major. The workflow then, in one shot: +# 1. computes the next version from the latest git tag, +# 2. stamps it into pyproject.toml and src/landingai_ade/_version.py, +# 3. prepends a changelog section (grouped by Conventional Commit prefixes) +# to CHANGELOG.md, +# 4. lands that as a "release: x.y.z" commit directly on main, +# 5. tags it and creates the GitHub Release, which triggers +# .github/workflows/publish-pypi.yml. # -# Ordinary PR merges never create release PRs or releases — releases happen -# only when a maintainer dispatches this workflow and merges the PR it opens. +# No release PR, no second confirmation: dispatching the workflow IS the +# release decision. Ordinary PR merges never trigger any of this. # -# RELEASE_PLEASE_TOKEN must be a fine-grained PAT (or GitHub App installation -# token) with Contents: write and Pull requests: write on this repository. -# The default GITHUB_TOKEN cannot be used: PRs and releases it creates do not -# trigger other workflows, so CI would not run on the release PR and -# publishing would silently never run. +# RELEASE_TOKEN requirements: +# - a fine-grained PAT (or GitHub App installation token) with +# Contents: write on this repository; +# - its owner must be able to bypass the main-branch ruleset (the ruleset +# requires PRs; this repo's ruleset grants "always" bypass to +# organization admins). Otherwise the direct push in step 4 is rejected. +# - The default GITHUB_TOKEN cannot be used: releases it creates do not +# trigger other workflows, so publishing would silently never run. name: Release on: @@ -33,23 +35,25 @@ on: - patch - minor - major - pull_request: - types: [closed] jobs: - open-release-pr: - name: open release PR - if: github.event_name == 'workflow_dispatch' && github.repository == 'landing-ai/ade-python' + release: + name: release runs-on: ubuntu-latest + if: github.repository == 'landing-ai/ade-python' && github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history: needed for the previous tag + changelog range + token: ${{ secrets.RELEASE_TOKEN }} - name: Compute next version id: version env: BUMP: ${{ inputs.bump }} run: | - current="$(jq -r '.["."]' .release-please-manifest.json)" + prev_tag="$(git describe --tags --abbrev=0)" + current="${prev_tag#v}" IFS='.' read -r major minor patch <<< "$current" case "$BUMP" in major) next="$((major + 1)).0.0" ;; @@ -57,34 +61,40 @@ jobs: patch) next="${major}.${minor}.$((patch + 1))" ;; esac echo "bumping $current -> $next ($BUMP)" - echo "version=$next" >> "$GITHUB_OUTPUT" + { + echo "prev_tag=$prev_tag" + echo "version=$next" + } >> "$GITHUB_OUTPUT" - - name: Open or update the release PR + - name: Stamp version env: - GITHUB_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + NEXT: ${{ steps.version.outputs.version }} run: | - npx --yes release-please@17.10.2 release-pr \ - --token="$GITHUB_TOKEN" \ - --repo-url="${{ github.repository }}" \ - --target-branch=main \ - --release-as="${{ steps.version.outputs.version }}" + sed -i -E "s/^version = \".*\"/version = \"$NEXT\"/" pyproject.toml + sed -i -E "s/__version__ = \".*\"/__version__ = \"$NEXT\"/" src/landingai_ade/_version.py + git diff --stat - finalize: - name: tag + GitHub release - if: >- - github.event_name == 'pull_request' && - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release-please--') && - github.repository == 'landing-ai/ade-python' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 + - name: Update changelog + env: + PREV_TAG: ${{ steps.version.outputs.prev_tag }} + NEXT: ${{ steps.version.outputs.version }} + run: | + python3 .github/scripts/generate_release_notes.py \ + "${{ github.repository }}" "$PREV_TAG" "$NEXT" > /tmp/release-notes.md + { head -n 2 CHANGELOG.md; cat /tmp/release-notes.md; echo; tail -n +3 CHANGELOG.md; } > /tmp/changelog.md + mv /tmp/changelog.md CHANGELOG.md - - name: Create tag and GitHub Release + - name: Commit, tag, and create the GitHub Release env: - GITHUB_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + NEXT: ${{ steps.version.outputs.version }} run: | - npx --yes release-please@17.10.2 github-release \ - --token="$GITHUB_TOKEN" \ - --repo-url="${{ github.repository }}" \ - --target-branch=main + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml src/landingai_ade/_version.py CHANGELOG.md + git commit -m "release: $NEXT" + git push origin HEAD:main + gh release create "v$NEXT" \ + --target "$(git rev-parse HEAD)" \ + --title "v$NEXT" \ + --notes-file /tmp/release-notes.md diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index de0960a..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "1.12.0" -} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4119d0..05466a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,9 @@ Please use [Conventional Commits](https://www.conventionalcommits.org/) for comm titles (`feat:`, `fix:`, `chore:`, …) — the release changelog is grouped by these prefixes. Releases are cut manually: a maintainer runs the **Release** workflow (Actions → Release → Run -workflow), chooses the version bump (patch / minor / major), and merges the release PR it opens. -Ordinary PR merges never trigger a release. +workflow) and chooses the version bump (patch / minor / major). The workflow lands a +`release: x.y.z` commit on `main` (version stamping + changelog), tags it, and creates the GitHub +Release — which triggers publishing. Ordinary PR merges never trigger a release. ## Adding and running examples diff --git a/docs/design/stainless-exit-and-v2.md b/docs/design/stainless-exit-and-v2.md index 76ad759..b320123 100644 --- a/docs/design/stainless-exit-and-v2.md +++ b/docs/design/stainless-exit-and-v2.md @@ -21,7 +21,7 @@ Exactly two things break, both verified: | Break | Evidence | Fix | |---|---|---| -| **Releases stop.** Today's release pipeline is split: the Stainless GitHub App runs release-please *externally* — it opens the release PR, and on merge creates the git tag and GitHub Release. The in-repo `publish-pypi.yml` / `publish-npm.yml` workflows then trigger on the release event and run on GitHub runners with our registry tokens — that half is already Stainless-free and survives unchanged. | Every recent tag and GitHub Release in both repos was authored by `stainless-app[bot]`; no release-please workflow exists in-repo. Stale bot release PRs open now: [ade-python#90 "release: 1.13.0"](https://github.com/landing-ai/ade-python/pull/90), [ade-typescript#67 "release: 2.8.0"](https://github.com/landing-ai/ade-typescript/pull/67) (both since April). | Replace only the orchestration half: close the stale bot release PRs and add a dispatch-only **Release** workflow — a maintainer picks patch/minor/major and the release-please CLI opens a release PR pinned to that version (the `release-please-config.json` already in each repo keeps doing changelog + version stamping). Publish workflows and tokens need no change. | +| **Releases stop.** Today's release pipeline is split: the Stainless GitHub App runs release-please *externally* — it opens the release PR, and on merge creates the git tag and GitHub Release. The in-repo `publish-pypi.yml` / `publish-npm.yml` workflows then trigger on the release event and run on GitHub runners with our registry tokens — that half is already Stainless-free and survives unchanged. | Every recent tag and GitHub Release in both repos was authored by `stainless-app[bot]`; no release-please workflow exists in-repo. Stale bot release PRs open now: [ade-python#90 "release: 1.13.0"](https://github.com/landing-ai/ade-python/pull/90), [ade-typescript#67 "release: 2.8.0"](https://github.com/landing-ai/ade-typescript/pull/67) (both since April). | Replace only the orchestration half: close the stale bot release PRs and add a dispatch-only **Release** workflow — a maintainer picks patch/minor/major; the workflow stamps versions + changelog, lands the `release: x.y.z` commit directly on `main` (via ruleset bypass), tags it, and creates the GitHub Release. Publish workflows and tokens need no change. | | **The TS build fetches a Stainless-hosted artifact.** | `package.json` pins `"tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz"`. When the `stainless-api` org is archived/removed, `npm install` breaks. | Vendor the tarball, or repoint to a fork under `landing-ai`. | One pre-existing failure surfaced while verifying the above, and the takeover inherits it: **every `publish-npm` run since v2.5.0 (April 6) is red.** The root `landingai-ade` package publishes fine (npm is current at 2.7.0; PyPI runs are all green at 1.12.0), but a later step fails — **`landingai-ade-mcp` (the MCP server) has never reached npm**, and the unmerged `fix/npm-oidc-auth` branch suggests a known auth cause. Fix it (or explicitly descope MCP npm publishing) as part of this problem, so that after takeover a green publish run actually means everything shipped. @@ -157,7 +157,7 @@ The automation targets the **staging** spec; releases gate on the **production** 1. API deploys to staging → staging spec changes → sync PR opens (or refreshes its branch). 2. Automated gates run against staging (contract tests, surface-lock) → review → merge to `main`. 3. QA tests merged `main` against staging (`pip install git+…@main` + `LANDINGAI_ADE_ENVIRONMENT=staging`) while the artifact is *not yet published*. -4. API deploys to production → the release gate (every SDK-implemented path exists in the production spec) passes → a maintainer dispatches the Release workflow (choosing the bump) and merges its release PR → PyPI/npm artifact ships. +4. API deploys to production → the release gate (every SDK-implemented path exists in the production spec) passes → a maintainer dispatches the Release workflow (choosing the bump) → PyPI/npm artifact ships. Two consequences to be explicit about: diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index 31caba6..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "packages": { - ".": {} - }, - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "include-component-in-tag": false, - "pull-request-header": "Automated Release PR", - "pull-request-title-pattern": "release: ${version}", - "changelog-sections": [ - { - "type": "feat", - "section": "Features" - }, - { - "type": "fix", - "section": "Bug Fixes" - }, - { - "type": "perf", - "section": "Performance Improvements" - }, - { - "type": "revert", - "section": "Reverts" - }, - { - "type": "chore", - "section": "Chores" - }, - { - "type": "docs", - "section": "Documentation" - }, - { - "type": "style", - "section": "Styles" - }, - { - "type": "refactor", - "section": "Refactors" - }, - { - "type": "test", - "section": "Tests", - "hidden": true - }, - { - "type": "build", - "section": "Build System" - }, - { - "type": "ci", - "section": "Continuous Integration", - "hidden": true - } - ], - "release-type": "python", - "extra-files": [ - "src/landingai_ade/_version.py" - ] -} \ No newline at end of file diff --git a/src/landingai_ade/_version.py b/src/landingai_ade/_version.py index 22cefc1..64b482f 100644 --- a/src/landingai_ade/_version.py +++ b/src/landingai_ade/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "landingai_ade" -__version__ = "1.12.0" # x-release-please-version +__version__ = "1.12.0" From 7f63c8a6791026fe2c84ba5370b49ab441444fa1 Mon Sep 17 00:00:00 2001 From: Mingrui Zhang Date: Tue, 7 Jul 2026 15:12:48 -0700 Subject: [PATCH 4/6] fix: satisfy repo lints in release-notes script Length-sorted imports with future annotations (isort length-sort + FA102), typed the bucket dict for strict pyright, and replaced print with sys.stdout.write (T201). Verified locally against the pinned ruff 0.14.13, pyright 1.1.399, and mypy 1.17.0. Co-Authored-By: Claude Fable 5 --- .github/scripts/generate_release_notes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py index 2e25979..423480a 100644 --- a/.github/scripts/generate_release_notes.py +++ b/.github/scripts/generate_release_notes.py @@ -9,9 +9,11 @@ Release notes. """ +from __future__ import annotations + import re -import subprocess import sys +import subprocess from datetime import date SECTIONS = [ @@ -39,7 +41,7 @@ def main() -> None: check=True, ).stdout - buckets = {} + buckets: dict[str, list[str]] = {} for line in log.splitlines(): if not line.strip(): continue @@ -67,7 +69,7 @@ def main() -> None: entries = buckets.get(kind, []) if entries: out += ["", f"### {title}", ""] + entries - print("\n".join(out)) + sys.stdout.write("\n".join(out) + "\n") if __name__ == "__main__": From b42ff51f9eee42287143324670e87d8f756f7ab5 Mon Sep 17 00:00:00 2001 From: Mingrui Zhang Date: Tue, 7 Jul 2026 15:16:55 -0700 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20release=20concurrency=20+=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a concurrency group to the Release workflow so concurrent dispatches queue instead of racing the version computation - Hoist SECTION_KEYS out of the per-commit loop in the notes script - Drop the Stainless-generated header from _version.py - Simplify release-doctor to dispatch-only (its PR trigger gated on release-please branch names that no longer exist) Co-Authored-By: Claude Fable 5 --- .github/scripts/generate_release_notes.py | 3 ++- .github/workflows/release-doctor.yml | 5 +---- .github/workflows/release.yml | 6 ++++++ src/landingai_ade/_version.py | 2 -- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py index 423480a..8ff2446 100644 --- a/.github/scripts/generate_release_notes.py +++ b/.github/scripts/generate_release_notes.py @@ -28,6 +28,7 @@ ("build", "Build System"), ("other", "Other Changes"), ] +SECTION_KEYS = {key for key, _ in SECTIONS} HIDDEN = {"test", "ci", "release"} CONVENTIONAL = re.compile(r"^(?P[a-z]+)(?:\((?P[^)]*)\))?!?:\s*(?P.+)$") @@ -51,7 +52,7 @@ def main() -> None: kind = match.group("type") if kind in HIDDEN: continue - if kind not in {key for key, _ in SECTIONS}: + if kind not in SECTION_KEYS: kind = "other" scope, desc = match.group("scope"), match.group("desc") text = f"**{scope}:** {desc}" if scope else desc diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index f8b7b98..271b4ab 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -1,15 +1,12 @@ name: Release Doctor on: - pull_request: - branches: - - main workflow_dispatch: jobs: release_doctor: name: release doctor runs-on: ubuntu-latest - if: github.repository == 'landing-ai/ade-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + if: github.repository == 'landing-ai/ade-python' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12780a0..6bcb92c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,12 @@ on: - minor - major +# Only one release can run at a time; a second dispatch queues rather than +# racing the version computation and the push to main. +concurrency: + group: release + cancel-in-progress: false + jobs: release: name: release diff --git a/src/landingai_ade/_version.py b/src/landingai_ade/_version.py index 64b482f..a210346 100644 --- a/src/landingai_ade/_version.py +++ b/src/landingai_ade/_version.py @@ -1,4 +1,2 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - __title__ = "landingai_ade" __version__ = "1.12.0" From c9aeb854c814f65e5a7fe978098a42661ab1a729 Mon Sep 17 00:00:00 2001 From: Mingrui Zhang Date: Tue, 7 Jul 2026 15:22:27 -0700 Subject: [PATCH 6/6] fix: gate the release on building and testing the stamped tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the one-shot release could previously tag and publish without validating the release commit itself. The workflow now runs bootstrap + rye build + tests on the stamped tree and aborts — before any commit, tag, or Release exists — if anything is red. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bcb92c..c33f1bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,10 @@ # 2. stamps it into pyproject.toml and src/landingai_ade/_version.py, # 3. prepends a changelog section (grouped by Conventional Commit prefixes) # to CHANGELOG.md, -# 4. lands that as a "release: x.y.z" commit directly on main, -# 5. tags it and creates the GitHub Release, which triggers +# 4. builds and tests the stamped tree — the release aborts here, before +# any commit or tag exists, if anything is red, +# 5. lands that as a "release: x.y.z" commit directly on main, +# 6. tags it and creates the GitHub Release, which triggers # .github/workflows/publish-pypi.yml. # # No release PR, no second confirmation: dispatching the workflow IS the @@ -90,6 +92,20 @@ jobs: { head -n 2 CHANGELOG.md; cat /tmp/release-notes.md; echo; tail -n +3 CHANGELOG.md; } > /tmp/changelog.md mv /tmp/changelog.md CHANGELOG.md + - 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: Build and test the stamped tree + run: | + ./scripts/bootstrap + rye build + ./scripts/test + - name: Commit, tag, and create the GitHub Release env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}