From 573b78d2d0e9407a60734cd1b3b261b0c86feba9 Mon Sep 17 00:00:00 2001 From: Kunwarvir Dhillon <243457111+kdhillon-stripe@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:39:54 -0400 Subject: [PATCH 1/4] feat(openapi): generate all spec versions from git history at Docker build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a build-time utility that walks the stripe/openapi git history to extract every published OpenAPI spec version, and wires it into the Docker image and the spec resolution flow. Changes: - packages/openapi/scripts/generate-all-specs.mjs: standalone Node ESM script (builtins only) — clones stripe/openapi with --filter=blob:none, walks git log for both spec paths (latest/openapi.spec3.sdk.json and openapi/spec3.json), deduplicates by info.version, writes .json + manifest.json to an output directory - Dockerfile: new spec-builder stage that runs generate-all-specs.mjs; output is copied into the build stage before pnpm deploy so all specs land in dist/generated-specs/ in the final image - specFetchHelper.ts: checks generated-specs/manifest.json (loaded once, cached in module scope) before falling through to network; silently skips if the directory is absent (local dev with no Docker build) - package.json build script: copies generated-specs to dist if present Co-Authored-By: Claude Sonnet 4.6 (1M context) Committed-By-Agent: claude --- Dockerfile | 8 +++ packages/openapi/package.json | 2 +- .../openapi/scripts/generate-all-specs.mjs | 59 +++++++++++++++++++ packages/openapi/specFetchHelper.ts | 46 +++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 packages/openapi/scripts/generate-all-specs.mjs diff --git a/Dockerfile b/Dockerfile index a3372357..4d102664 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +# Pre-generate OpenAPI specs from the stripe/openapi repo +FROM node:24-alpine AS spec-builder +RUN apk add --no-cache git +RUN git clone --filter=blob:none https://github.com/stripe/openapi /stripe-openapi +COPY packages/openapi/scripts/generate-all-specs.mjs /generate-all-specs.mjs +RUN node /generate-all-specs.mjs /stripe-openapi /generated-specs + # Install deps and create standalone deployment # Expects pre-built dist/ directories in the build context (from `pnpm build`) FROM node:24-alpine AS build @@ -9,6 +16,7 @@ RUN corepack enable WORKDIR /app COPY . ./ +COPY --from=spec-builder /generated-specs ./packages/openapi/generated-specs RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm --filter @stripe/sync-engine deploy --prod /deploy diff --git a/packages/openapi/package.json b/packages/openapi/package.json index e318054c..30c86b96 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -11,7 +11,7 @@ } }, "scripts": { - "build": "tsc && cp bundled-spec.json dist/bundled-spec.json", + "build": "tsc && cp bundled-spec.json dist/bundled-spec.json && (cp -r generated-specs dist/generated-specs 2>/dev/null || true)", "test": "vitest --passWithNoTests" }, "files": [ diff --git a/packages/openapi/scripts/generate-all-specs.mjs b/packages/openapi/scripts/generate-all-specs.mjs new file mode 100644 index 00000000..27a4960f --- /dev/null +++ b/packages/openapi/scripts/generate-all-specs.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { execSync } from 'child_process' +import { writeFileSync, mkdirSync } from 'fs' +import { join } from 'path' + +const [repoPath, outputDir] = process.argv.slice(2) +if (!repoPath || !outputDir) { + console.error('Usage: node generate-all-specs.mjs ') + process.exit(1) +} + +const execOpts = { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + +let log +try { + log = execSync( + `git -C ${repoPath} log --format="%H" -- latest/openapi.spec3.sdk.json openapi/spec3.json`, + execOpts + ) +} catch (err) { + console.error(`Fatal: cannot access repo at ${repoPath}: ${err.message}`) + process.exit(1) +} + +const shas = log.trim().split('\n').filter(Boolean) +mkdirSync(outputDir, { recursive: true }) + +const seen = new Map() +const paths = ['latest/openapi.spec3.sdk.json', 'openapi/spec3.json'] + +for (const sha of shas) { + let raw = null + for (const p of paths) { + try { + raw = execSync(`git -C ${repoPath} show ${sha}:${p}`, execOpts) + break + } catch { + // file doesn't exist at this path for this commit + } + } + if (!raw) continue + + let version + try { + version = JSON.parse(raw).info?.version + } catch { + continue + } + if (!version || seen.has(version)) continue + + const filename = `${version}.json` + writeFileSync(join(outputDir, filename), raw) + seen.set(version, filename) + console.error(`Generated ${version}`) +} + +const manifest = Object.fromEntries(seen) +writeFileSync(join(outputDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') +console.error(`Done: ${seen.size} specs from ${shas.length} commits`) diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index cb1a8e49..b74e03d1 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -42,6 +42,17 @@ export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise | null | undefined = undefined + +async function tryLoadPreGeneratedSpec( + apiVersion: string +): Promise<{ spec: OpenApiSpec; path: string } | null> { + try { + if (generatedSpecManifest === null) return null + + const generatedDir = fileURLToPath(new URL('./generated-specs', import.meta.url)) + + if (generatedSpecManifest === undefined) { + try { + const manifestRaw = await fs.readFile(path.join(generatedDir, 'manifest.json'), 'utf8') + generatedSpecManifest = JSON.parse(manifestRaw) as Record + } catch { + generatedSpecManifest = null + return null + } + } + + const requestedDate = extractDatePart(apiVersion) + const matchedKey = Object.keys(generatedSpecManifest).find( + (key) => extractDatePart(key) === requestedDate + ) + if (!matchedKey) return null + + const specPath = path.join(generatedDir, generatedSpecManifest[matchedKey]) + const spec = await readSpecFromPath(specPath) + return { spec, path: specPath } + } catch { + return null + } +} + async function readSpecFromPath(openApiSpecPath: string): Promise { const raw = await fs.readFile(openApiSpecPath, 'utf8') let parsed: unknown From 516a7bb2ee260e27c77d145a0a2ce74b924a46e2 Mon Sep 17 00:00:00 2001 From: Kunwarvir Dhillon <243457111+kdhillon-stripe@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:20:41 -0400 Subject: [PATCH 2/4] fix(openapi): use node: prefix in script, hoist generatedDir to module scope Co-Authored-By: Claude Sonnet 4.6 (1M context) Committed-By-Agent: claude --- packages/openapi/scripts/generate-all-specs.mjs | 6 +++--- packages/openapi/specFetchHelper.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/openapi/scripts/generate-all-specs.mjs b/packages/openapi/scripts/generate-all-specs.mjs index 27a4960f..d5076f42 100644 --- a/packages/openapi/scripts/generate-all-specs.mjs +++ b/packages/openapi/scripts/generate-all-specs.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { execSync } from 'child_process' -import { writeFileSync, mkdirSync } from 'fs' -import { join } from 'path' +import { execSync } from 'node:child_process' +import { writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' const [repoPath, outputDir] = process.argv.slice(2) if (!repoPath || !outputDir) { diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index b74e03d1..700d8974 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -6,6 +6,7 @@ import type { OpenApiSpec, ResolveSpecConfig, ResolvedOpenApiSpec } from './type import { fetchWithProxy } from './transport.js' const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache') +const GENERATED_SPECS_DIR = fileURLToPath(new URL('./generated-specs', import.meta.url)) // The spec bundled into this package at build time. // Update this constant and bundled-spec.json together when bumping. @@ -97,11 +98,12 @@ async function tryLoadPreGeneratedSpec( try { if (generatedSpecManifest === null) return null - const generatedDir = fileURLToPath(new URL('./generated-specs', import.meta.url)) - if (generatedSpecManifest === undefined) { try { - const manifestRaw = await fs.readFile(path.join(generatedDir, 'manifest.json'), 'utf8') + const manifestRaw = await fs.readFile( + path.join(GENERATED_SPECS_DIR, 'manifest.json'), + 'utf8' + ) generatedSpecManifest = JSON.parse(manifestRaw) as Record } catch { generatedSpecManifest = null @@ -115,7 +117,7 @@ async function tryLoadPreGeneratedSpec( ) if (!matchedKey) return null - const specPath = path.join(generatedDir, generatedSpecManifest[matchedKey]) + const specPath = path.join(GENERATED_SPECS_DIR, generatedSpecManifest[matchedKey]) const spec = await readSpecFromPath(specPath) return { spec, path: specPath } } catch { From 2205f19f8ac4a63b503eaac96c973e7814d0e5c6 Mon Sep 17 00:00:00 2001 From: Kunwarvir Dhillon <243457111+kdhillon-stripe@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:25:40 -0400 Subject: [PATCH 3/4] refactor(openapi): rename bundled-spec to latest-spec Co-Authored-By: Claude Sonnet 4.6 (1M context) Committed-By-Agent: claude --- .../openapi/{bundled-spec.json => latest-spec.json} | 0 packages/openapi/package.json | 2 +- packages/openapi/specFetchHelper.ts | 12 ++++++------ 3 files changed, 7 insertions(+), 7 deletions(-) rename packages/openapi/{bundled-spec.json => latest-spec.json} (100%) diff --git a/packages/openapi/bundled-spec.json b/packages/openapi/latest-spec.json similarity index 100% rename from packages/openapi/bundled-spec.json rename to packages/openapi/latest-spec.json diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 30c86b96..b0a85732 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -11,7 +11,7 @@ } }, "scripts": { - "build": "tsc && cp bundled-spec.json dist/bundled-spec.json && (cp -r generated-specs dist/generated-specs 2>/dev/null || true)", + "build": "tsc && cp latest-spec.json dist/latest-spec.json && (cp -r generated-specs dist/generated-specs 2>/dev/null || true)", "test": "vitest --passWithNoTests" }, "files": [ diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index 700d8974..b7c91a54 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -9,8 +9,8 @@ const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache') const GENERATED_SPECS_DIR = fileURLToPath(new URL('./generated-specs', import.meta.url)) // 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' +// Update this constant and latest-spec.json together when bumping. +export const LATEST_BUNDLED_API_VERSION = '2026-03-25.dahlia' export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise { const apiVersion = config.apiVersion @@ -32,14 +32,14 @@ export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise Date: Mon, 30 Mar 2026 20:35:04 -0400 Subject: [PATCH 4/4] refactor(openapi): use indexed signature type for manifest Co-Authored-By: Claude Sonnet 4.6 (1M context) Committed-By-Agent: claude --- packages/openapi/specFetchHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index b7c91a54..a72aa672 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -90,7 +90,7 @@ export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise | null | undefined = undefined +let generatedSpecManifest: { [version: string]: string } | null | undefined = undefined async function tryLoadPreGeneratedSpec( apiVersion: string @@ -104,7 +104,7 @@ async function tryLoadPreGeneratedSpec( path.join(GENERATED_SPECS_DIR, 'manifest.json'), 'utf8' ) - generatedSpecManifest = JSON.parse(manifestRaw) as Record + generatedSpecManifest = JSON.parse(manifestRaw) as { [version: string]: string } } catch { generatedSpecManifest = null return null