From ef900815258676e62ea66232b3ef82afda91dfaf Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 11:16:52 -0700 Subject: [PATCH 1/8] feat(openapi): publish Stripe API specs to Vercel CDN (stripe-sync.dev) - Add docs/scripts/generate-stripe-specs.mjs: fetches every published Stripe REST API spec version via GitHub Commits API + raw.githubusercontent.com, writes .json + manifest.json + index.html to outputDir. Queries both spec paths (latest/openapi.spec3.sdk.json and openapi/spec3.json) to cover historic and current versions (~51 total). - Update docs/build.mjs to run the generator into out/stripe-api-specs/ during Vercel build; skip via SKIP_STRIPE_SPECS=1. - Add CDN fallback tier to resolveOpenApiSpec (specFetchHelper.ts): checks stripe-sync.dev/stripe-api-specs/manifest.json before hitting GitHub API, avoiding rate limits for consumers. Override with STRIPE_SPEC_CDN_BASE_URL env var. - Export BUNDLED_API_VERSION from packages/openapi index. - Add 'cdn' to ResolvedOpenApiSpec.source union type. - Add ci: pass GITHUB_TOKEN to Deploy Docs build step for authenticated GitHub API calls during spec generation. - Add e2e/openapi-cdn.test.ts: skips gracefully if CDN not deployed, verifies manifest reachable + spot-checks spec files + resolveOpenApiSpec returns source:'cdn'. These are the official Stripe REST API specs (github.com/stripe/openapi), NOT the Sync Engine's own OpenAPI spec. Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- .github/workflows/ci.yml | 1 + docs/build.mjs | 28 +++- docs/scripts/generate-stripe-specs.mjs | 147 ++++++++++++++++++ e2e/openapi-cdn.test.ts | 79 ++++++++++ e2e/package.json | 1 + .../openapi/__tests__/specFetchHelper.test.ts | 27 +++- packages/openapi/index.ts | 2 +- packages/openapi/specFetchHelper.ts | 49 ++++++ packages/openapi/types.ts | 2 +- pnpm-lock.yaml | 3 + 10 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 docs/scripts/generate-stripe-specs.mjs create mode 100644 e2e/openapi-cdn.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cac604d..f7e8fb64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -415,6 +415,7 @@ jobs: env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build slides run: | diff --git a/docs/build.mjs b/docs/build.mjs index 64fef2b2..801c4b6e 100644 --- a/docs/build.mjs +++ b/docs/build.mjs @@ -1,9 +1,10 @@ import Markdoc from '@markdoc/markdoc' import fs from 'node:fs' import path from 'node:path' +import { spawnSync } from 'node:child_process' const ROOT = path.dirname(new URL(import.meta.url).pathname) -const PAGES_DIR = ROOT +const PAGES_DIR = path.join(ROOT, 'pages') const PUBLIC_DIR = path.join(ROOT, 'public') const OPENAPI_DIR = path.join(ROOT, 'openapi') const OUT_DIR = path.join(ROOT, 'out') @@ -26,21 +27,34 @@ fs.mkdirSync(OUT_DIR, { recursive: true }) // Copy public assets if (fs.existsSync(PUBLIC_DIR)) copyDir(PUBLIC_DIR, OUT_DIR) -// Copy OpenAPI specs +// Copy Sync Engine OpenAPI specs (engine/service/webhook) → /openapi/ if (fs.existsSync(OPENAPI_DIR)) copyDir(OPENAPI_DIR, path.join(OUT_DIR, 'openapi')) -// Collect all .md files recursively, skipping non-content dirs -const SKIP_DIRS = new Set(['node_modules', 'out', 'openapi', 'public', 'slides']) +// Generate official Stripe API specs (from stripe/openapi) → /stripe-api-specs/ +// These are the upstream Stripe REST API specs, NOT the Sync Engine API. +// Served as a CDN mirror so consumers avoid GitHub rate limits. +// Skipped when SKIP_STRIPE_SPECS=1 (e.g. quick local builds). +if (process.env.SKIP_STRIPE_SPECS !== '1') { + console.log('Generating Stripe API specs...') + const stripeSpecDir = path.join(OUT_DIR, 'stripe-api-specs') + const result = spawnSync( + process.execPath, + [path.join(ROOT, 'scripts', 'generate-stripe-specs.mjs'), stripeSpecDir], + { stdio: 'inherit' } + ) + if (result.status !== 0) { + console.error('Warning: Stripe API spec generation failed — CDN specs will be unavailable') + } +} +// Collect all .md files recursively under PAGES_DIR function collectPages(dir, base = '') { const entries = fs.readdirSync(dir, { withFileTypes: true }) const pages = [] for (const entry of entries) { const rel = base ? `${base}/${entry.name}` : entry.name if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) { - pages.push(...collectPages(path.join(dir, entry.name), rel)) - } + pages.push(...collectPages(path.join(dir, entry.name), rel)) } else if (entry.name.endsWith('.md')) { pages.push(rel) } diff --git a/docs/scripts/generate-stripe-specs.mjs b/docs/scripts/generate-stripe-specs.mjs new file mode 100644 index 00000000..bf897a34 --- /dev/null +++ b/docs/scripts/generate-stripe-specs.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Fetches every published Stripe REST API spec version from GitHub and writes + * .json + manifest.json to . + * + * Usage: + * node generate-stripe-specs.mjs + * + * The output lands at docs/out/stripe-api-specs/ during the Vercel build and + * is served from stripe-sync.dev/stripe-api-specs — no GitHub rate limits for consumers. + * + * These are the official Stripe REST API specs (github.com/stripe/openapi), NOT + * the Sync Engine's own OpenAPI spec (which lives at /openapi/engine.json etc.). + * + * Uses the GitHub REST API + raw.githubusercontent.com (no git clone required). + * Set GITHUB_TOKEN / GH_TOKEN to avoid the 60 req/h unauthenticated rate limit. + * + * No npm dependencies. + */ +import { writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +const [outputDir] = process.argv.slice(2) +if (!outputDir) { + console.error('Usage: node generate-stripe-specs.mjs ') + process.exit(1) +} + +const OWNER = 'stripe' +const REPO = 'openapi' +// Both historic and current spec paths in the stripe/openapi repo +const SPEC_PATHS = ['latest/openapi.spec3.sdk.json', 'openapi/spec3.json'] +const TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN + +function githubHeaders() { + const h = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'stripe-sync-engine-spec-generator', + } + if (TOKEN) h['Authorization'] = `Bearer ${TOKEN}` + return h +} + +async function githubApi(path) { + const res = await fetch(`https://api.github.com/repos/${OWNER}/${REPO}${path}`, { + headers: githubHeaders(), + }) + if (!res.ok) { + throw new Error(`GitHub API ${res.status} for ${path}: ${await res.text()}`) + } + return res.json() +} + +async function fetchRaw(sha, specPath) { + const url = `https://raw.githubusercontent.com/${OWNER}/${REPO}/${sha}/${specPath}` + const res = await fetch(url, { headers: { 'User-Agent': 'stripe-sync-engine-spec-generator' } }) + return res.ok ? res.text() : null +} + +// Collect all commits that touched either spec path (paginated). +// stripe/openapi uses 'latest/openapi.spec3.sdk.json' for recent specs and +// 'openapi/spec3.json' for historic ones — query both and deduplicate. +console.error('Fetching commit list from GitHub API...') +const seenShas = new Set() +const allShas = [] +for (const specPath of SPEC_PATHS) { + for (let page = 1; ; page++) { + const commits = await githubApi(`/commits?path=${specPath}&per_page=100&page=${page}`) + for (const c of commits) { + if (!seenShas.has(c.sha)) { + seenShas.add(c.sha) + allShas.push(c.sha) + } + } + if (commits.length < 100) break + } +} +console.error(` ${allShas.length} commits to scan`) + +mkdirSync(outputDir, { recursive: true }) + +const seen = new Map() +for (const sha of allShas) { + let raw = null + for (const specPath of SPEC_PATHS) { + raw = await fetchRaw(sha, specPath) + if (raw) break + } + if (!raw) continue + + let version + try { + version = JSON.parse(raw).info?.version + } catch { + continue + } + if (!version || seen.has(version)) continue + + writeFileSync(join(outputDir, `${version}.json`), raw) + seen.set(version, `${version}.json`) + console.error(` ${version}`) +} + +writeFileSync( + join(outputDir, 'manifest.json'), + JSON.stringify(Object.fromEntries(seen), null, 2) + '\n' +) + +// Generate an index page so https://stripe-sync.dev/stripe-api-specs/ is browsable +const versions = [...seen.keys()].sort().reverse() +const rows = versions.map((v) => `
  • ${v}
  • `).join('\n') +writeFileSync( + join(outputDir, 'index.html'), + ` + + + + Stripe REST API Specs — stripe-sync.dev CDN + + + +

    Stripe REST API OpenAPI Specs

    +

    + These are the official Stripe REST API specs from + github.com/stripe/openapi, + mirrored here to avoid GitHub API rate limits. + This is not the Sync Engine's own OpenAPI spec + (see engine.json for that). +

    +

    Machine-readable index: manifest.json — ${versions.length} versions available.

    +
      +${rows} +
    + + +` +) + +console.error(`\nDone: ${seen.size} spec versions from ${allShas.length} commits`) diff --git a/e2e/openapi-cdn.test.ts b/e2e/openapi-cdn.test.ts new file mode 100644 index 00000000..adf7c1c1 --- /dev/null +++ b/e2e/openapi-cdn.test.ts @@ -0,0 +1,79 @@ +/** + * E2E test: Stripe spec CDN at stripe-sync.dev/openapi/stripe + * + * Verifies that: + * 1. manifest.json is accessible and lists known spec versions + * 2. resolveOpenApiSpec returns source:'cdn' for a non-bundled version + * + * No env vars required — hits the live CDN directly. + * Tests are skipped automatically when the CDN isn't deployed yet (404). + * Once deployed, any failure here means the CDN is broken. + */ +import os from 'node:os' +import path from 'node:path' +import { beforeAll, describe, it, expect } from 'vitest' +import { resolveOpenApiSpec, BUNDLED_API_VERSION } from '@stripe/sync-openapi' + +const CDN_BASE = process.env.STRIPE_SPEC_CDN_BASE_URL ?? 'https://stripe-sync.dev/stripe-api-specs' + +describe('Stripe spec CDN', () => { + let manifest: Record | null = null + + beforeAll(async () => { + const res = await fetch(`${CDN_BASE}/manifest.json`).catch(() => null) + if (!res || !res.ok) { + console.warn( + `Skipping CDN tests — ${CDN_BASE}/manifest.json returned ${res?.status ?? 'no response'} (CDN not deployed yet)` + ) + return + } + manifest = (await res.json()) as Record + }) + + it('manifest.json is reachable and lists spec versions', () => { + if (!manifest) return // CDN not deployed yet + + const versions = Object.keys(manifest) + expect(versions.length, 'manifest should list at least one version').toBeGreaterThan(0) + + for (const [version, filename] of Object.entries(manifest)) { + expect(filename, `manifest entry for ${version}`).toMatch(/^.+\.json$/) + } + }) + + it('each manifest entry resolves to a valid OpenAPI spec', async () => { + if (!manifest) return // CDN not deployed yet + + // Spot-check the first 3 entries to keep the test fast + const entries = Object.entries(manifest).slice(0, 3) + for (const [version, filename] of entries) { + const specRes = await fetch(`${CDN_BASE}/${filename}`) + expect(specRes.status, `GET ${CDN_BASE}/${filename}`).toBe(200) + + const spec = (await specRes.json()) as Record + expect(typeof spec.openapi, `spec ${version} missing openapi field`).toBe('string') + expect(spec.components, `spec ${version} missing components`).toBeTruthy() + } + }) + + it('resolveOpenApiSpec uses cdn source for non-bundled version', async () => { + if (!manifest) return // CDN not deployed yet + + // Find any version that isn't the bundled one + const nonBundled = Object.keys(manifest).find( + (v) => !v.startsWith(BUNDLED_API_VERSION.slice(0, 10)) + ) + if (!nonBundled) { + console.warn('Only the bundled version is in the CDN manifest — skipping cdn source check') + return + } + + // Use an isolated cache dir so we don't hit a pre-existing cache entry + const cacheDir = path.join(os.tmpdir(), `openapi-cdn-test-${Date.now()}`) + const result = await resolveOpenApiSpec({ apiVersion: nonBundled, cacheDir }) + + expect(result.source).toBe('cdn') + expect(result.apiVersion).toBe(nonBundled) + expect(typeof result.spec.openapi).toBe('string') + }) +}) diff --git a/e2e/package.json b/e2e/package.json index 188ad3e5..3beb1145 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,6 +7,7 @@ }, "devDependencies": { "@hono/node-server": "^1", + "@stripe/sync-openapi": "workspace:*", "@stripe/sync-protocol": "workspace:*", "@stripe/sync-engine": "workspace:*", "@stripe/sync-service": "workspace:*", diff --git a/packages/openapi/__tests__/specFetchHelper.test.ts b/packages/openapi/__tests__/specFetchHelper.test.ts index a6bb5a78..8cddea6a 100644 --- a/packages/openapi/__tests__/specFetchHelper.test.ts +++ b/packages/openapi/__tests__/specFetchHelper.test.ts @@ -9,6 +9,20 @@ async function createTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`)) } +// Mock fetch that returns 404 for CDN URLs (not deployed in tests) and +// handles GitHub API calls normally. +function makeFetchMock( + githubHandler: (url: string) => Response | Promise +): typeof globalThis.fetch { + return vi.fn(async (input: URL | string) => { + const url = String(input) + if (url.includes('stripe-sync.dev')) { + return new Response('not found', { status: 404 }) + } + return githubHandler(url) + }) as unknown as typeof globalThis.fetch +} + describe('resolveOpenApiSpec', () => { it('prefers explicit local spec path over cache and network', async () => { const tempDir = await createTempDir('openapi-explicit') @@ -52,8 +66,7 @@ describe('resolveOpenApiSpec', () => { it('fetches from GitHub when cache misses and persists cache', async () => { const tempDir = await createTempDir('openapi-fetch') - const fetchMock = vi.fn(async (input: URL | string) => { - const url = String(input) + const fetchMock = makeFetchMock((url) => { if (url.includes('/commits')) { return new Response(JSON.stringify([{ sha: 'abc123def456' }]), { status: 200 }) } @@ -73,14 +86,14 @@ describe('resolveOpenApiSpec', () => { const cached = await fs.readFile(path.join(tempDir, '2020-08-27.spec3.sdk.json'), 'utf8') expect(JSON.parse(cached)).toMatchObject({ openapi: '3.0.0' }) - expect(fetchMock).toHaveBeenCalledTimes(2) + // 1 CDN manifest (404) + 1 GitHub commits + 1 GitHub spec + expect(fetchMock).toHaveBeenCalledTimes(3) await fs.rm(tempDir, { recursive: true, force: true }) }) it('uses the injected fetch for GitHub fetches', async () => { const tempDir = await createTempDir('openapi-fetch-proxy') - const fetchMock = vi.fn(async (input: URL | string) => { - const url = String(input) + const fetchMock = makeFetchMock((url) => { if (url.includes('/commits')) { return new Response(JSON.stringify([{ sha: 'abc123def456' }]), { status: 200 }) } @@ -92,7 +105,8 @@ describe('resolveOpenApiSpec', () => { fetchMock ) expect(result.source).toBe('github') - expect(fetchMock).toHaveBeenCalledTimes(2) + // 1 CDN manifest (404) + 1 GitHub commits + 1 GitHub spec + expect(fetchMock).toHaveBeenCalledTimes(3) } finally { await fs.rm(tempDir, { recursive: true, force: true }) } @@ -117,6 +131,7 @@ describe('resolveOpenApiSpec', () => { it('fails fast when GitHub resolution fails and no explicit spec path is set', async () => { const tempDir = await createTempDir('openapi-fail-fast') + // CDN returns 500 → tryFetchFromCdn returns null → falls through to GitHub (500) → throws const fetchMock = vi.fn(async () => new Response('boom', { status: 500 })) await expect( diff --git a/packages/openapi/index.ts b/packages/openapi/index.ts index 8976e358..4fad34cf 100644 --- a/packages/openapi/index.ts +++ b/packages/openapi/index.ts @@ -2,7 +2,7 @@ export type * from './types.js' export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES } from './specParser.js' export { OPENAPI_COMPATIBILITY_COLUMNS } from './runtimeMappings.js' -export { resolveOpenApiSpec } from './specFetchHelper.js' +export { resolveOpenApiSpec, BUNDLED_API_VERSION } from './specFetchHelper.js' export { discoverListEndpoints, discoverNestedEndpoints, diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index e576ed2b..d6caaccf 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -6,6 +6,13 @@ import type { OpenApiSpec, ResolveSpecConfig, ResolvedOpenApiSpec } from './type const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache') +// CDN mirror of the official Stripe REST API specs (from github.com/stripe/openapi). +// Served from stripe-sync.dev — no auth, no GitHub rate limits. +// These are the upstream Stripe API specs, NOT the Sync Engine's own OpenAPI spec. +// Override with STRIPE_SPEC_CDN_BASE_URL env var (e.g. in tests or self-hosting). +const STRIPE_SPEC_CDN_BASE_URL = + process.env.STRIPE_SPEC_CDN_BASE_URL ?? 'https://stripe-sync.dev/stripe-api-specs' + // The spec bundled into this package at build time. // Update this constant and bundled-spec.json together when bumping. export const BUNDLED_API_VERSION = '2026-03-25.dahlia' @@ -56,6 +63,19 @@ export async function resolveOpenApiSpec( } } + // Try the Vercel CDN mirror before falling back to the GitHub API. + // The CDN serves spec versions without auth or rate limits. + const cdnSpec = await tryFetchFromCdn(apiVersion, fetch) + if (cdnSpec) { + await tryWriteCache(cachePath, cdnSpec) + return { + apiVersion, + spec: cdnSpec, + source: 'cdn', + cachePath, + } + } + let commitSha = await resolveCommitShaForApiVersion(apiVersion, fetch) if (!commitSha) { commitSha = await resolveLatestCommitSha(fetch) @@ -113,6 +133,35 @@ async function tryWriteCache(cachePath: string, spec: OpenApiSpec): Promise { + // The CDN manifest maps "YYYY-MM-DD.codename" → "YYYY-MM-DD.codename.json". + // We match by date part so "2026-03-25" resolves to "2026-03-25.dahlia.json". + if (!STRIPE_SPEC_CDN_BASE_URL) return null + try { + const manifestUrl = `${STRIPE_SPEC_CDN_BASE_URL}/manifest.json` + const manifestRes = await fetch(manifestUrl) + if (!manifestRes.ok) return null + + const manifest = (await manifestRes.json()) as Record + const datePart = extractDatePart(apiVersion) + const filename = Object.keys(manifest).find((v) => extractDatePart(v) === datePart) + if (!filename) return null + + const specUrl = `${STRIPE_SPEC_CDN_BASE_URL}/${manifest[filename]}` + const specRes = await fetch(specUrl) + if (!specRes.ok) return null + + const spec = (await specRes.json()) as unknown + validateOpenApiSpec(spec) + return spec + } catch { + return null + } +} + function getCachePath(cacheDir: string, apiVersion: string): string { const safeVersion = apiVersion.replace(/[^0-9a-zA-Z_-]/g, '_') return path.join(cacheDir, `${safeVersion}.spec3.sdk.json`) diff --git a/packages/openapi/types.ts b/packages/openapi/types.ts index b9564440..8c72e471 100644 --- a/packages/openapi/types.ts +++ b/packages/openapi/types.ts @@ -114,7 +114,7 @@ export type ResolveSpecConfig = { export type ResolvedOpenApiSpec = { apiVersion: string spec: OpenApiSpec - source: 'explicit_path' | 'cache' | 'github' | 'bundled' + source: 'explicit_path' | 'cache' | 'cdn' | 'github' | 'bundled' cachePath?: string commitSha?: string } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12206b52..203fb376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@stripe/sync-engine': specifier: workspace:* version: link:../apps/engine + '@stripe/sync-openapi': + specifier: workspace:* + version: link:../packages/openapi '@stripe/sync-protocol': specifier: workspace:* version: link:../packages/protocol From 690835da9bd89531c5d9c5a4788ca8dd8bf1db48 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 21:55:16 -0700 Subject: [PATCH 2/8] ci: deploy docs on all pushes, add CDN e2e test after deploy - Remove push-only guard from docs job so branch deployments trigger Vercel - Branch pushes get a preview URL; main pushes deploy to production - Capture deployment URL as job output and print to step summary with branch name - Add e2e_cdn job (needs: docs) that runs openapi-cdn.test.ts against the deployed URL Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7e8fb64..5ae593da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,12 +389,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # --------------------------------------------------------------------------- - # Docs — deploy to Vercel (only on push, not PRs) + # Docs — deploy to Vercel (always, so e2e_cdn can test the live CDN) # --------------------------------------------------------------------------- docs: name: Deploy Docs - if: ${{ github.event_name == 'push' }} runs-on: ubuntu-24.04-arm + outputs: + deployment_url: ${{ steps.deploy.outputs.url }} steps: - uses: actions/checkout@v5 @@ -403,14 +404,18 @@ jobs: run: npm install -g vercel - name: Pull Vercel config - run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + run: | + ENV=${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + vercel pull --yes --environment=$ENV --token=${{ secrets.VERCEL_TOKEN }} working-directory: docs env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} - name: Build - run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + run: | + PROD=${{ github.ref == 'refs/heads/main' && '--prod' || '' }} + vercel build $PROD --token=${{ secrets.VERCEL_TOKEN }} working-directory: docs env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} @@ -429,13 +434,55 @@ jobs: working-directory: docs/slides - name: Deploy - run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} + id: deploy + run: | + PROD=${{ github.ref == 'refs/heads/main' && '--prod' || '' }} + BRANCH_SLUG="${GITHUB_REF_NAME//\//-}" + url=$(vercel deploy --prebuilt $PROD \ + --meta gitBranch="$GITHUB_REF_NAME" \ + --meta gitCommit="$GITHUB_SHA" \ + --token=${{ secrets.VERCEL_TOKEN }}) + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "### Vercel deployment" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** \`$GITHUB_REF_NAME\`" >> "$GITHUB_STEP_SUMMARY" + echo "**URL:** $url" >> "$GITHUB_STEP_SUMMARY" + echo "**CDN:** $url/stripe-api-specs/manifest.json" >> "$GITHUB_STEP_SUMMARY" + echo "" + echo "Deployed: $url (branch: $GITHUB_REF_NAME)" working-directory: docs env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} - # Deployed to: https://stripe-sync-engine.dev/ + # main → https://stripe-sync.dev/ | branch → unique preview URL + + # --------------------------------------------------------------------------- + # E2E CDN — verify stripe-sync.dev/stripe-api-specs after Vercel deploy + # --------------------------------------------------------------------------- + e2e_cdn: + name: E2E CDN + needs: [docs] + runs-on: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: ./.nvmrc + cache: pnpm + + - name: Install dependencies & build + run: pnpm install --frozen-lockfile && pnpm build + + - name: CDN e2e tests + run: pnpm --filter @stripe/sync-e2e run test -- openapi-cdn.test.ts + env: + STRIPE_SPEC_CDN_BASE_URL: ${{ needs.docs.outputs.deployment_url }}/stripe-api-specs # --------------------------------------------------------------------------- # Docker Hub — promote the built GHCR image to Docker Hub tags From 0fa5544d05c5ce1394ef376bc354c7640171c05f Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 22:16:05 -0700 Subject: [PATCH 3/8] feat(docs): use blobless git clone for stripe/openapi specs Replace GitHub REST API + raw.githubusercontent.com with a blobless git clone: - No auth required (drops GITHUB_TOKEN from vercel build step) - No rate limits (was 60 req/h without token) - ls-tree reads tree objects locally (no network per-commit) - Only unique blobs are fetched via cat-file (deduplicates identical content across commits) - STRIPE_OPENAPI_REPO env var allows injecting a pre-cloned path (e.g. from CI cache) to skip the clone entirely Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- .github/workflows/ci.yml | 1 - docs/scripts/generate-stripe-specs.mjs | 109 ++++++++++++------------- 2 files changed, 53 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae593da..6f9c3993 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -420,7 +420,6 @@ jobs: env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build slides run: | diff --git a/docs/scripts/generate-stripe-specs.mjs b/docs/scripts/generate-stripe-specs.mjs index bf897a34..66bb6c57 100644 --- a/docs/scripts/generate-stripe-specs.mjs +++ b/docs/scripts/generate-stripe-specs.mjs @@ -1,24 +1,23 @@ #!/usr/bin/env node /** - * Fetches every published Stripe REST API spec version from GitHub and writes - * .json + manifest.json to . + * Fetches every published Stripe REST API spec version from github.com/stripe/openapi + * and writes .json + manifest.json to . * * Usage: * node generate-stripe-specs.mjs * - * The output lands at docs/out/stripe-api-specs/ during the Vercel build and - * is served from stripe-sync.dev/stripe-api-specs — no GitHub rate limits for consumers. + * Uses a blobless git clone — no GitHub API rate limits, no auth required. + * Set STRIPE_OPENAPI_REPO to a pre-cloned path to skip the clone (e.g. from CI cache). * * These are the official Stripe REST API specs (github.com/stripe/openapi), NOT * the Sync Engine's own OpenAPI spec (which lives at /openapi/engine.json etc.). * - * Uses the GitHub REST API + raw.githubusercontent.com (no git clone required). - * Set GITHUB_TOKEN / GH_TOKEN to avoid the 60 req/h unauthenticated rate limit. - * * No npm dependencies. */ -import { writeFileSync, mkdirSync } from 'node:fs' +import { writeFileSync, mkdirSync, existsSync } from 'node:fs' import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { execFileSync } from 'node:child_process' const [outputDir] = process.argv.slice(2) if (!outputDir) { @@ -26,67 +25,65 @@ if (!outputDir) { process.exit(1) } -const OWNER = 'stripe' -const REPO = 'openapi' -// Both historic and current spec paths in the stripe/openapi repo +const REPO_URL = 'https://github.com/stripe/openapi' +// stripe/openapi uses 'latest/openapi.spec3.sdk.json' for recent specs and +// 'openapi/spec3.json' for historic ones. const SPEC_PATHS = ['latest/openapi.spec3.sdk.json', 'openapi/spec3.json'] -const TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN -function githubHeaders() { - const h = { - Accept: 'application/vnd.github+json', - 'User-Agent': 'stripe-sync-engine-spec-generator', - } - if (TOKEN) h['Authorization'] = `Bearer ${TOKEN}` - return h +function git(...args) { + return execFileSync('git', ['-C', repoDir, ...args], { encoding: 'utf8' }) } -async function githubApi(path) { - const res = await fetch(`https://api.github.com/repos/${OWNER}/${REPO}${path}`, { - headers: githubHeaders(), - }) - if (!res.ok) { - throw new Error(`GitHub API ${res.status} for ${path}: ${await res.text()}`) - } - return res.json() +// Clone or use pre-cloned repo (STRIPE_OPENAPI_REPO lets CI inject a cached clone) +const repoDir = process.env.STRIPE_OPENAPI_REPO ?? join(tmpdir(), 'stripe-openapi') +if (!existsSync(join(repoDir, '.git'))) { + console.error(`Cloning ${REPO_URL} (blobless)...`) + execFileSync( + 'git', + ['clone', '--filter=blob:none', '--no-tags', '--single-branch', REPO_URL, repoDir], + { stdio: 'inherit' } + ) +} else { + console.error(`Using pre-cloned repo at ${repoDir}`) } -async function fetchRaw(sha, specPath) { - const url = `https://raw.githubusercontent.com/${OWNER}/${REPO}/${sha}/${specPath}` - const res = await fetch(url, { headers: { 'User-Agent': 'stripe-sync-engine-spec-generator' } }) - return res.ok ? res.text() : null -} +// Find all commits that touched either spec path. +// ls-tree reads tree objects (included in blobless clone) — no network needed here. +console.error('Finding relevant commits...') +const commits = git('log', '--format=%H', '--', ...SPEC_PATHS).trim().split('\n').filter(Boolean) +console.error(` ${commits.length} commits`) -// Collect all commits that touched either spec path (paginated). -// stripe/openapi uses 'latest/openapi.spec3.sdk.json' for recent specs and -// 'openapi/spec3.json' for historic ones — query both and deduplicate. -console.error('Fetching commit list from GitHub API...') -const seenShas = new Set() -const allShas = [] -for (const specPath of SPEC_PATHS) { - for (let page = 1; ; page++) { - const commits = await githubApi(`/commits?path=${specPath}&per_page=100&page=${page}`) - for (const c of commits) { - if (!seenShas.has(c.sha)) { - seenShas.add(c.sha) - allShas.push(c.sha) - } +// Collect unique blob SHAs via ls-tree (local, no network) to avoid re-fetching duplicates. +// Two commits that share a blob SHA have identical content → only fetch once. +const blobToPath = new Map() // blobSha -> specPath +for (const commit of commits) { + for (const specPath of SPEC_PATHS) { + let ls + try { + ls = git('ls-tree', commit, specPath).trim() + } catch { + continue + } + if (!ls) continue + const blobSha = ls.split(/\s+/)[2] + if (!blobToPath.has(blobSha)) { + blobToPath.set(blobSha, specPath) } - if (commits.length < 100) break + break // one spec per commit is enough } } -console.error(` ${allShas.length} commits to scan`) +console.error(` ${blobToPath.size} unique blobs to fetch`) mkdirSync(outputDir, { recursive: true }) -const seen = new Map() -for (const sha of allShas) { - let raw = null - for (const specPath of SPEC_PATHS) { - raw = await fetchRaw(sha, specPath) - if (raw) break +const seen = new Map() // version -> filename +for (const [blobSha] of blobToPath) { + let raw + try { + raw = git('cat-file', 'blob', blobSha) // fetches just this blob on-demand + } catch { + continue } - if (!raw) continue let version try { @@ -144,4 +141,4 @@ ${rows} ` ) -console.error(`\nDone: ${seen.size} spec versions from ${allShas.length} commits`) +console.error(`\nDone: ${seen.size} spec versions from ${commits.length} commits`) From 06cc7f5ee94779d052009c61746000d69b3010ce Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 22:59:56 -0700 Subject: [PATCH 4/8] =?UTF-8?q?fix(docs):=20fix=20spec=20generator=20?= =?UTF-8?q?=E2=80=94=20use=20full=20clone,=20limit=20to=203=20versions,=20?= =?UTF-8?q?fix=20PAGES=5FDIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch from blobless clone to single-branch clone so blobs are available locally (blobless clone caused 0 spec versions: git cat-file fetches were failing silently) - Default maxVersions to 3 for simplicity (was 5) - Fix PAGES_DIR from nonexistent `docs/pages` to `docs/` root Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- docs/build.mjs | 2 +- docs/scripts/generate-stripe-specs.mjs | 68 +++++++++++++------------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/docs/build.mjs b/docs/build.mjs index 801c4b6e..49ec4219 100644 --- a/docs/build.mjs +++ b/docs/build.mjs @@ -4,7 +4,7 @@ import path from 'node:path' import { spawnSync } from 'node:child_process' const ROOT = path.dirname(new URL(import.meta.url).pathname) -const PAGES_DIR = path.join(ROOT, 'pages') +const PAGES_DIR = ROOT const PUBLIC_DIR = path.join(ROOT, 'public') const OPENAPI_DIR = path.join(ROOT, 'openapi') const OUT_DIR = path.join(ROOT, 'out') diff --git a/docs/scripts/generate-stripe-specs.mjs b/docs/scripts/generate-stripe-specs.mjs index 66bb6c57..f5f51e38 100644 --- a/docs/scripts/generate-stripe-specs.mjs +++ b/docs/scripts/generate-stripe-specs.mjs @@ -1,13 +1,14 @@ #!/usr/bin/env node /** - * Fetches every published Stripe REST API spec version from github.com/stripe/openapi - * and writes .json + manifest.json to . + * Fetches the N most recent published Stripe REST API spec versions from + * github.com/stripe/openapi and writes .json + manifest.json to . * * Usage: - * node generate-stripe-specs.mjs + * node generate-stripe-specs.mjs [maxVersions=5] * - * Uses a blobless git clone — no GitHub API rate limits, no auth required. - * Set STRIPE_OPENAPI_REPO to a pre-cloned path to skip the clone (e.g. from CI cache). + * Clones stripe/openapi (single-branch) then walks history newest→oldest, + * stopping once maxVersions unique API versions are collected. + * Set STRIPE_OPENAPI_REPO to a pre-cloned path to skip the clone (e.g. CI cache). * * These are the official Stripe REST API specs (github.com/stripe/openapi), NOT * the Sync Engine's own OpenAPI spec (which lives at /openapi/engine.json etc.). @@ -19,11 +20,12 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' -const [outputDir] = process.argv.slice(2) +const [outputDir, maxVersionsArg] = process.argv.slice(2) if (!outputDir) { - console.error('Usage: node generate-stripe-specs.mjs ') + console.error('Usage: node generate-stripe-specs.mjs [maxVersions=5]') process.exit(1) } +const MAX_VERSIONS = parseInt(maxVersionsArg ?? '3') const REPO_URL = 'https://github.com/stripe/openapi' // stripe/openapi uses 'latest/openapi.spec3.sdk.json' for recent specs and @@ -34,29 +36,32 @@ function git(...args) { return execFileSync('git', ['-C', repoDir, ...args], { encoding: 'utf8' }) } -// Clone or use pre-cloned repo (STRIPE_OPENAPI_REPO lets CI inject a cached clone) +// Clone or use pre-cloned repo const repoDir = process.env.STRIPE_OPENAPI_REPO ?? join(tmpdir(), 'stripe-openapi') if (!existsSync(join(repoDir, '.git'))) { - console.error(`Cloning ${REPO_URL} (blobless)...`) - execFileSync( - 'git', - ['clone', '--filter=blob:none', '--no-tags', '--single-branch', REPO_URL, repoDir], - { stdio: 'inherit' } - ) + console.error(`Cloning ${REPO_URL}...`) + execFileSync('git', ['clone', '--single-branch', REPO_URL, repoDir], { stdio: 'inherit' }) } else { console.error(`Using pre-cloned repo at ${repoDir}`) } -// Find all commits that touched either spec path. -// ls-tree reads tree objects (included in blobless clone) — no network needed here. -console.error('Finding relevant commits...') -const commits = git('log', '--format=%H', '--', ...SPEC_PATHS).trim().split('\n').filter(Boolean) -console.error(` ${commits.length} commits`) +// Walk commits newest→oldest, collect up to MAX_VERSIONS unique API versions. +// Stops early — only fetches as many blobs as needed. +console.error(`Collecting ${MAX_VERSIONS} most recent spec versions...`) +const commits = git('log', '--format=%H', '--', ...SPEC_PATHS) + .trim() + .split('\n') + .filter(Boolean) + +mkdirSync(outputDir, { recursive: true }) + +const seen = new Map() // version -> filename +const seenBlobs = new Set() -// Collect unique blob SHAs via ls-tree (local, no network) to avoid re-fetching duplicates. -// Two commits that share a blob SHA have identical content → only fetch once. -const blobToPath = new Map() // blobSha -> specPath for (const commit of commits) { + if (seen.size >= MAX_VERSIONS) break + + let blobSha for (const specPath of SPEC_PATHS) { let ls try { @@ -65,22 +70,15 @@ for (const commit of commits) { continue } if (!ls) continue - const blobSha = ls.split(/\s+/)[2] - if (!blobToPath.has(blobSha)) { - blobToPath.set(blobSha, specPath) - } - break // one spec per commit is enough + blobSha = ls.split(/\s+/)[2] + break } -} -console.error(` ${blobToPath.size} unique blobs to fetch`) + if (!blobSha || seenBlobs.has(blobSha)) continue + seenBlobs.add(blobSha) -mkdirSync(outputDir, { recursive: true }) - -const seen = new Map() // version -> filename -for (const [blobSha] of blobToPath) { let raw try { - raw = git('cat-file', 'blob', blobSha) // fetches just this blob on-demand + raw = git('cat-file', 'blob', blobSha) } catch { continue } @@ -141,4 +139,4 @@ ${rows} ` ) -console.error(`\nDone: ${seen.size} spec versions from ${commits.length} commits`) +console.error(`\nDone: ${seen.size} spec versions`) From 8127d7963e197c3a7fbc2974ec63688479340771 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 23:19:58 -0700 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20two=20CI=20fixes=20=E2=80=94=20spec?= =?UTF-8?q?=20buffer=20overflow=20and=20source-stripe=20api=5Fversion=20de?= =?UTF-8?q?fault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate-stripe-specs.mjs: set maxBuffer: 50MB for git cat-file; Stripe specs are ~10MB so the 1MB default was silently throwing, yielding 0 spec versions - source-stripe: default api_version to BUNDLED_API_VERSION instead of '2020-08-27' so tests without an explicit api_version use the bundled spec (no network needed) Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- docs/scripts/generate-stripe-specs.mjs | 6 +++++- packages/source-stripe/src/index.ts | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/scripts/generate-stripe-specs.mjs b/docs/scripts/generate-stripe-specs.mjs index f5f51e38..33d83b21 100644 --- a/docs/scripts/generate-stripe-specs.mjs +++ b/docs/scripts/generate-stripe-specs.mjs @@ -33,7 +33,11 @@ const REPO_URL = 'https://github.com/stripe/openapi' const SPEC_PATHS = ['latest/openapi.spec3.sdk.json', 'openapi/spec3.json'] function git(...args) { - return execFileSync('git', ['-C', repoDir, ...args], { encoding: 'utf8' }) + // maxBuffer: Stripe specs are ~10 MB each; default 1 MB would silently truncate/throw. + return execFileSync('git', ['-C', repoDir, ...args], { + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024, + }) } // Clone or use pre-cloned repo diff --git a/packages/source-stripe/src/index.ts b/packages/source-stripe/src/index.ts index 20d8d26b..5ed48501 100644 --- a/packages/source-stripe/src/index.ts +++ b/packages/source-stripe/src/index.ts @@ -10,6 +10,7 @@ import { buildResourceRegistry } from './resourceRegistry.js' import { catalogFromRegistry, catalogFromOpenApi } from './catalog.js' import { resolveOpenApiSpec, + BUNDLED_API_VERSION, SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES, } from '@stripe/sync-openapi' @@ -153,7 +154,7 @@ export function createStripeSource( async discover({ config }) { const resolved = await resolveOpenApiSpec( - { apiVersion: config.api_version ?? '2020-08-27' }, + { apiVersion: config.api_version ?? BUNDLED_API_VERSION }, apiFetch ) const registry = buildResourceRegistry( @@ -216,7 +217,7 @@ export function createStripeSource( externalRateLimiter ?? createInMemoryRateLimiter(config.rate_limit ?? DEFAULT_MAX_RPS) const stripe = makeClient(config) const resolved = await resolveOpenApiSpec( - { apiVersion: config.api_version ?? '2020-08-27' }, + { apiVersion: config.api_version ?? BUNDLED_API_VERSION }, apiFetch ) const registry = buildResourceRegistry( From 687361ff35d4ab8717aa8d9e844114385cad2809 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 23:28:34 -0700 Subject: [PATCH 6/8] fix(e2e): pass fetch to resolveOpenApiSpec in CDN test Required after the inject-fetch refactor in v2. Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- e2e/openapi-cdn.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/openapi-cdn.test.ts b/e2e/openapi-cdn.test.ts index adf7c1c1..6d86bffb 100644 --- a/e2e/openapi-cdn.test.ts +++ b/e2e/openapi-cdn.test.ts @@ -70,7 +70,7 @@ describe('Stripe spec CDN', () => { // Use an isolated cache dir so we don't hit a pre-existing cache entry const cacheDir = path.join(os.tmpdir(), `openapi-cdn-test-${Date.now()}`) - const result = await resolveOpenApiSpec({ apiVersion: nonBundled, cacheDir }) + const result = await resolveOpenApiSpec({ apiVersion: nonBundled, cacheDir }, fetch) expect(result.source).toBe('cdn') expect(result.apiVersion).toBe(nonBundled) From 382f0693d8ab8d45c4793e71ef97c80125d505cc Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 23:45:43 -0700 Subject: [PATCH 7/8] fix(docs): collect all spec versions and fix trailing-slash link bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove MAX_VERSIONS cap — collect every unique API version from history since the full clone already has everything; blob-SHA dedup keeps it fast - Add trailingSlash: true to vercel.json so /stripe-api-specs redirects to /stripe-api-specs/ before serving index.html, fixing relative links Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- docs/scripts/generate-stripe-specs.mjs | 20 ++++++++------------ docs/vercel.json | 3 ++- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/scripts/generate-stripe-specs.mjs b/docs/scripts/generate-stripe-specs.mjs index 33d83b21..569506d8 100644 --- a/docs/scripts/generate-stripe-specs.mjs +++ b/docs/scripts/generate-stripe-specs.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node /** - * Fetches the N most recent published Stripe REST API spec versions from + * Fetches all published Stripe REST API spec versions from * github.com/stripe/openapi and writes .json + manifest.json to . * * Usage: - * node generate-stripe-specs.mjs [maxVersions=5] + * node generate-stripe-specs.mjs * - * Clones stripe/openapi (single-branch) then walks history newest→oldest, - * stopping once maxVersions unique API versions are collected. + * Clones stripe/openapi (single-branch) then walks the full history, collecting + * every unique API version (deduplicated by blob SHA, then by version string). * Set STRIPE_OPENAPI_REPO to a pre-cloned path to skip the clone (e.g. CI cache). * * These are the official Stripe REST API specs (github.com/stripe/openapi), NOT @@ -20,12 +20,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' -const [outputDir, maxVersionsArg] = process.argv.slice(2) +const [outputDir] = process.argv.slice(2) if (!outputDir) { - console.error('Usage: node generate-stripe-specs.mjs [maxVersions=5]') + console.error('Usage: node generate-stripe-specs.mjs ') process.exit(1) } -const MAX_VERSIONS = parseInt(maxVersionsArg ?? '3') const REPO_URL = 'https://github.com/stripe/openapi' // stripe/openapi uses 'latest/openapi.spec3.sdk.json' for recent specs and @@ -49,9 +48,8 @@ if (!existsSync(join(repoDir, '.git'))) { console.error(`Using pre-cloned repo at ${repoDir}`) } -// Walk commits newest→oldest, collect up to MAX_VERSIONS unique API versions. -// Stops early — only fetches as many blobs as needed. -console.error(`Collecting ${MAX_VERSIONS} most recent spec versions...`) +// Walk commits newest→oldest, collect all unique API versions. +console.error('Collecting all spec versions...') const commits = git('log', '--format=%H', '--', ...SPEC_PATHS) .trim() .split('\n') @@ -63,8 +61,6 @@ const seen = new Map() // version -> filename const seenBlobs = new Set() for (const commit of commits) { - if (seen.size >= MAX_VERSIONS) break - let blobSha for (const specPath of SPEC_PATHS) { let ls diff --git a/docs/vercel.json b/docs/vercel.json index 57494a21..e0b26ad0 100644 --- a/docs/vercel.json +++ b/docs/vercel.json @@ -1,4 +1,5 @@ { "buildCommand": "npm run build", - "outputDirectory": "out" + "outputDirectory": "out", + "trailingSlash": true } From 8802b6deb5ee15ed400033eb87d041c063d5a030 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Wed, 1 Apr 2026 00:02:20 -0700 Subject: [PATCH 8/8] docs: add Stripe API specs CDN link to home page Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- docs/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.md b/docs/index.md index 65dac38b..0c7164ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,6 +36,7 @@ npx @stripe/sync-engine sync \ ## API Reference - [Engine API](/engine.html) — HTTP API for running syncs +- [Stripe API Specs](/stripe-api-specs/) — CDN mirror of official Stripe OpenAPI specs (all versions) ## Slides