Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -428,13 +433,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
Expand Down
26 changes: 20 additions & 6 deletions docs/build.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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
Expand All @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
142 changes: 142 additions & 0 deletions docs/scripts/generate-stripe-specs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Fetches all published Stripe REST API spec versions from
* github.com/stripe/openapi and writes <version>.json + manifest.json to <outputDir>.
*
* Usage:
* node generate-stripe-specs.mjs <outputDir>
*
* 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
* the Sync Engine's own OpenAPI spec (which lives at /openapi/engine.json etc.).
*
* No npm dependencies.
*/
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) {
console.error('Usage: node generate-stripe-specs.mjs <outputDir>')
process.exit(1)
}

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']

function git(...args) {
// 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
const repoDir = process.env.STRIPE_OPENAPI_REPO ?? join(tmpdir(), 'stripe-openapi')
if (!existsSync(join(repoDir, '.git'))) {
console.error(`Cloning ${REPO_URL}...`)
execFileSync('git', ['clone', '--single-branch', REPO_URL, repoDir], { stdio: 'inherit' })
} else {
console.error(`Using pre-cloned repo at ${repoDir}`)
}

// 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')
.filter(Boolean)

mkdirSync(outputDir, { recursive: true })

const seen = new Map() // version -> filename
const seenBlobs = new Set()

for (const commit of commits) {
let blobSha
for (const specPath of SPEC_PATHS) {
let ls
try {
ls = git('ls-tree', commit, specPath).trim()
} catch {
continue
}
if (!ls) continue
blobSha = ls.split(/\s+/)[2]
break
}
if (!blobSha || seenBlobs.has(blobSha)) continue
seenBlobs.add(blobSha)

let raw
try {
raw = git('cat-file', 'blob', blobSha)
} catch {
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) => ` <li><a href="${seen.get(v)}">${v}</a></li>`).join('\n')
writeFileSync(
join(outputDir, 'index.html'),
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Stripe REST API Specs — stripe-sync.dev CDN</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; }
p { color: #555; }
ul { list-style: none; padding: 0; }
li { margin: .25rem 0; }
a { color: #5469d4; text-decoration: none; font-family: monospace; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Stripe REST API OpenAPI Specs</h1>
<p>
These are the official <strong>Stripe REST API</strong> specs from
<a href="https://github.com/stripe/openapi">github.com/stripe/openapi</a>,
mirrored here to avoid GitHub API rate limits.
This is <em>not</em> the Sync Engine's own OpenAPI spec
(see <a href="/openapi/engine.json">engine.json</a> for that).
</p>
<p>Machine-readable index: <a href="manifest.json">manifest.json</a> — ${versions.length} versions available.</p>
<ul>
${rows}
</ul>
</body>
</html>
`
)

console.error(`\nDone: ${seen.size} spec versions`)
3 changes: 2 additions & 1 deletion docs/vercel.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"buildCommand": "npm run build",
"outputDirectory": "out"
"outputDirectory": "out",
"trailingSlash": true
}
79 changes: 79 additions & 0 deletions e2e/openapi-cdn.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<string, string>
})

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<string, unknown>
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 }, fetch)

expect(result.source).toBe('cdn')
expect(result.apiVersion).toBe(nonBundled)
expect(typeof result.spec.openapi).toBe('string')
})
})
1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Loading