From ee72ca1bf54fd9fd9fdff92e992dbea78516d48e Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Tue, 13 Oct 2026 09:47:28 +0530 Subject: [PATCH 1/6] feat(openapi): reconcile generated CLI commands --- .github/dependabot.yml | 4 + .github/workflows/reconcile-openapi.yml | 202 ++++++++++++++++++ .github/workflows/source-checks.yml | 10 +- src/commands/generated/.openapi-source-sha | 1 + src/commands/generated/docs-operations.ts | 4 +- src/commands/generated/non_subscription.ts | 4 +- .../generated-operation-names.test.ts | 7 + .../unit/tools/audit-operation-ids.test.ts | 39 +++- src/tools/audit-operation-ids.ts | 43 ++++ src/tools/generate.ts | 71 +++++- 10 files changed, 368 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/reconcile-openapi.yml create mode 100644 src/commands/generated/.openapi-source-sha diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 536f63f..97faaa5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,6 +17,10 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + # OpenAPI reconciliation owns this bundled runtime dependency so its + # version, generated commands, license, and notices move in one PR. + ignore: + - dependency-name: "@chargebee/code-sample-generator" groups: dev-dependencies: dependency-type: development diff --git a/.github/workflows/reconcile-openapi.yml b/.github/workflows/reconcile-openapi.yml new file mode 100644 index 0000000..366f145 --- /dev/null +++ b/.github/workflows/reconcile-openapi.yml @@ -0,0 +1,202 @@ +name: Reconcile OpenAPI commands + +# Both upstream inputs converge through one fixed-branch PR: +# - a public-spec event can update generated commands immediately when the +# installed generator already covers every operation; +# - a generator-package event retries the same reconciliation after npm publish. +# The compatibility gate prevents a CLI release with unsupported operation IDs. + +on: + repository_dispatch: + types: [openapi-spec-published, code-sample-generator-released] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: reconcile-openapi-commands + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + reconcile: + name: Reconcile dependency and commands + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.4.2" + + - name: Resolve immutable inputs + id: inputs + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + EVENT_TYPE: ${{ github.event.action }} + DISPATCH_SOURCE_REPO: ${{ github.event.client_payload.source_repo }} + DISPATCH_SOURCE_SHA: ${{ github.event.client_payload.source_sha }} + DISPATCH_PACKAGE: ${{ github.event.client_payload.package }} + DISPATCH_PACKAGE_VERSION: ${{ github.event.client_payload.version }} + run: | + if [ "$EVENT_NAME" = "repository_dispatch" ] && [ "$EVENT_TYPE" = "openapi-spec-published" ]; then + if [ "$DISPATCH_SOURCE_REPO" != "chargebee/openapi" ]; then + echo "::error::Unexpected OpenAPI dispatch source: ${DISPATCH_SOURCE_REPO:-}" + exit 1 + fi + if [[ ! "$DISPATCH_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid dispatched OpenAPI SHA: ${DISPATCH_SOURCE_SHA:-}" + exit 1 + fi + gh api "repos/chargebee/openapi/commits/${DISPATCH_SOURCE_SHA}" --jq '.sha' >/dev/null + fi + + if [ "$EVENT_NAME" = "repository_dispatch" ] && [ "$EVENT_TYPE" = "code-sample-generator-released" ]; then + if [ "$DISPATCH_PACKAGE" != "@chargebee/code-sample-generator" ]; then + echo "::error::Unexpected package dispatch: ${DISPATCH_PACKAGE:-}" + exit 1 + fi + if [[ ! "$DISPATCH_PACKAGE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid dispatched package version: ${DISPATCH_PACKAGE_VERSION:-}" + exit 1 + fi + PUBLISHED_VERSION=$(npm view "@chargebee/code-sample-generator@${DISPATCH_PACKAGE_VERSION}" version 2>/dev/null || true) + if [ "$PUBLISHED_VERSION" != "$DISPATCH_PACKAGE_VERSION" ]; then + echo "::error::Dispatched package version is not available from npm: ${DISPATCH_PACKAGE_VERSION}" + exit 1 + fi + fi + + SPEC_SHA=$(gh api --method GET repos/chargebee/openapi/commits -f path=spec -f per_page=1 --jq '.[0].sha') + if [[ ! "$SPEC_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Could not resolve the latest chargebee/openapi spec commit" + exit 1 + fi + if [ -n "$DISPATCH_SOURCE_SHA" ] && [ "$DISPATCH_SOURCE_SHA" != "$SPEC_SHA" ]; then + echo "::warning::Dispatch carried ${DISPATCH_SOURCE_SHA}; reconciling to newer spec commit ${SPEC_SHA}." + fi + + GENERATOR_VERSION=$(npm view @chargebee/code-sample-generator@latest version) + if [[ ! "$GENERATOR_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Could not resolve the latest code-sample-generator version" + exit 1 + fi + if [ -n "$DISPATCH_PACKAGE_VERSION" ] && [ "$DISPATCH_PACKAGE_VERSION" != "$GENERATOR_VERSION" ]; then + echo "::warning::Dispatch carried package ${DISPATCH_PACKAGE_VERSION}; reconciling to npm latest ${GENERATOR_VERSION}." + fi + + echo "spec_sha=${SPEC_SHA}" >> "$GITHUB_OUTPUT" + echo "generator_version=${GENERATOR_VERSION}" >> "$GITHUB_OUTPUT" + echo "Using chargebee/openapi@${SPEC_SHA} and @chargebee/code-sample-generator@${GENERATOR_VERSION}" + + - name: Install current dependencies + run: bun install --frozen-lockfile + + - name: Update code-sample generator + env: + GENERATOR_VERSION: ${{ steps.inputs.outputs.generator_version }} + run: | + bun add --dev --exact "@chargebee/code-sample-generator@${GENERATOR_VERSION}" + cp node_modules/@chargebee/code-sample-generator/LICENSE licenses/code-sample-generator.txt + + - name: Regenerate commands from immutable public specs + env: + SPEC_SHA: ${{ steps.inputs.outputs.spec_sha }} + run: bun run src/tools/generate.ts --refresh --source-sha "$SPEC_SHA" + + - name: Refresh and verify third-party notices + run: | + bun run build:notices + bun run src/tools/third-party-notices.ts --check + + - name: Verify deterministic generation and compatibility + env: + SPEC_SHA: ${{ steps.inputs.outputs.spec_sha }} + run: | + FIRST_HASH=$(find src/commands/generated -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d ' ' -f 1) + bun run src/tools/generate.ts --offline --source-sha "$SPEC_SHA" + SECOND_HASH=$(find src/commands/generated -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d ' ' -f 1) + if [ "$FIRST_HASH" != "$SECOND_HASH" ]; then + echo "::error::CLI command generation is not deterministic" + exit 1 + fi + test "$(cat src/commands/generated/.openapi-source-sha)" = "$SPEC_SHA" + bun test src/tests/unit/tools/audit-operation-ids.test.ts src/tests/integration/generated-operation-names.test.ts + bun run typecheck + + - name: Validate proposed changes + id: changes + run: | + git diff --check + UNEXPECTED=$(git status --porcelain --untracked-files=all | awk ' + $2 != "package.json" && + $2 != "bun.lock" && + $2 != "THIRD_PARTY_NOTICES.txt" && + $2 != "licenses/code-sample-generator.txt" && + $2 !~ /^src\/commands\/generated\// { print } + ') + if [ -n "$UNEXPECTED" ]; then + echo "::error::Reconciliation changed files outside the approved dependency and generated-command paths" + echo "$UNEXPECTED" + exit 1 + fi + + if [ -n "$(git status --porcelain --untracked-files=all)" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + git status --short + git diff --stat + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "CLI already matches the latest public spec and generator package." + fi + + - name: Create pull-request token + if: steps.changes.outputs.changed == 'true' + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ secrets.CLI_AUTOMATION_APP_ID }} + private-key: ${{ secrets.CLI_AUTOMATION_APP_PRIVATE_KEY }} + owner: chargebee + repositories: cli + + - name: Create or update reconciliation PR + if: steps.changes.outputs.changed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 + with: + token: ${{ steps.app-token.outputs.token }} + base: main + branch: automation/reconcile-openapi + delete-branch: true + add-paths: | + package.json + bun.lock + THIRD_PARTY_NOTICES.txt + licenses/code-sample-generator.txt + src/commands/generated + commit-message: "fix(cli): reconcile OpenAPI commands and code-sample generator" + title: "fix(cli): reconcile OpenAPI commands and code-sample generator" + body: | + ## Summary + + Reconciles the generated API commands and embedded code-sample generator as one compatible unit. + + - Public spec: [`chargebee/openapi@${{ steps.inputs.outputs.spec_sha }}`](https://github.com/chargebee/openapi/commit/${{ steps.inputs.outputs.spec_sha }}) + - Generator package: `@chargebee/code-sample-generator@${{ steps.inputs.outputs.generator_version }}` + - Verified every non-empty generated operation ID exists in the installed generator + - Verified deterministic command generation, focused tests, type checking, licenses, and notices + + Nothing is auto-merged. Review the generated command diff before merging; the existing Release Please workflow handles the CLI release afterward. + + > Generated by [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/.github/workflows/source-checks.yml b/.github/workflows/source-checks.yml index 9f09ebb..8ab5aaf 100644 --- a/.github/workflows/source-checks.yml +++ b/.github/workflows/source-checks.yml @@ -78,14 +78,12 @@ jobs: - run: bun install --frozen-lockfile - # Regenerates API commands from the public OpenAPI specs. This job has - # no .cache/specs/, so it always fetches. Locally, bun run generate - # reuses that cache unless --refresh is passed. Missing operationIds are - # not a failure (an empty id just means no code sample for that op); the - # real gate is the drift check below. + # Regenerates API commands from one immutable public-spec commit. The + # generator also verifies that every mapped operation exists in the + # installed code-sample-generator package. - name: Regenerate API commands run: | - echo "Fetching the public OpenAPI specs. Locally, 'bun run generate' reuses .cache/specs/ unless you pass --refresh." + echo "Resolving and fetching one immutable public OpenAPI revision." bun run src/tools/generate.ts # Fails if the committed generated files drift from the specs. diff --git a/src/commands/generated/.openapi-source-sha b/src/commands/generated/.openapi-source-sha new file mode 100644 index 0000000..dcf540b --- /dev/null +++ b/src/commands/generated/.openapi-source-sha @@ -0,0 +1 @@ +62be342fa9029a5a4a80826fb63b8d1ca6dfd8c5 diff --git a/src/commands/generated/docs-operations.ts b/src/commands/generated/docs-operations.ts index a2fec66..2303595 100644 --- a/src/commands/generated/docs-operations.ts +++ b/src/commands/generated/docs-operations.ts @@ -1312,8 +1312,8 @@ export const docsOperationIds: Record { const resource = takeResourceId(id, json, command); - const params = await loadOperationParams(opts.data ?? [], resource.fromStdin, command, { method: "POST", jsonInput: false, opIdV2: "one_time_purchase", opIdV1: "one_time_purchase", pcVersionFlag: opts.pcVersion }); - if (opts.codeSample) return handleCodeSample({ lang: opts.codeSample, opIdV2: "one_time_purchase", opIdV1: "one_time_purchase", method: "POST", uri: "/non_subscriptions/{id}/one_time_purchase", dataFlags: opts.data ?? [], params, resourceId: resource.id, pathParamName: "non-subscription-app-id", pcVersionFlag: opts.pcVersion }); + const params = await loadOperationParams(opts.data ?? [], resource.fromStdin, command, { method: "POST", jsonInput: false, opIdV2: "", opIdV1: "", pcVersionFlag: opts.pcVersion }); + if (opts.codeSample) return handleCodeSample({ lang: opts.codeSample, opIdV2: "", opIdV1: "", method: "POST", uri: "/non_subscriptions/{id}/one_time_purchase", dataFlags: opts.data ?? [], params, resourceId: resource.id, pathParamName: "non-subscription-app-id", pcVersionFlag: opts.pcVersion }); if (!resource.id) command.error("error: missing required argument 'non-subscription-app-id'"); assertResourceId(resource.id, command); await ensureWriteAllowed("POST"); diff --git a/src/tests/integration/generated-operation-names.test.ts b/src/tests/integration/generated-operation-names.test.ts index 29f7e5e..d683e17 100644 --- a/src/tests/integration/generated-operation-names.test.ts +++ b/src/tests/integration/generated-operation-names.test.ts @@ -62,6 +62,13 @@ describe("generated API operation names", () => { } }); + it("does not advertise code samples for generator-unsupported SDK resources", () => { + expect(docsOperationIds["non-subscription"]?.["process-receipt"]).toEqual({ + v1: "", + v2: "", + }); + }); + it("runs each command through its canonical and previous name", async () => { for (const [resource, names] of Object.entries(previousNames)) { for (const [canonicalName, previousName] of Object.entries(names)) { diff --git a/src/tests/unit/tools/audit-operation-ids.test.ts b/src/tests/unit/tools/audit-operation-ids.test.ts index 5fc086b..226eed2 100644 --- a/src/tests/unit/tools/audit-operation-ids.test.ts +++ b/src/tests/unit/tools/audit-operation-ids.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { auditOperationIds, type AuditableResource } from "../../../tools/audit-operation-ids.js"; +import { + auditDescriptorCompatibility, + auditOperationIds, + type AuditableResource, + type DescriptorVersion, +} from "../../../tools/audit-operation-ids.js"; function op(sdkAction: string, v2: string, v1: string) { return { sdkAction, operationIdV2: v2, operationIdV1: v1 }; @@ -52,3 +57,35 @@ describe("auditOperationIds (spec-driven, no allowlist)", () => { ]); }); }); + +describe("auditDescriptorCompatibility", () => { + const resources: AuditableResource[] = [ + { + sdkName: "customer", + operations: [ + op("list", "list_customers", "list_customers"), + op("create", "create_a_customer", ""), + op("unmapped", "", ""), + ], + }, + ]; + + it("reports every mapped operation absent from the installed descriptors", () => { + const installed = new Set(["v2-pcv1:list_customers", "v2-pcv2:create_a_customer"]); + const hasDescriptor = (version: DescriptorVersion, operationId: string) => + installed.has(`${version}:${operationId}`); + + expect(auditDescriptorCompatibility(resources, hasDescriptor).missing).toEqual([ + { + command: "customer.list", + version: "v2-pcv2", + operationId: "list_customers", + }, + ]); + }); + + it("ignores empty ids because that catalog has no code sample", () => { + const hasDescriptor = () => true; + expect(auditDescriptorCompatibility(resources, hasDescriptor).missing).toEqual([]); + }); +}); diff --git a/src/tools/audit-operation-ids.ts b/src/tools/audit-operation-ids.ts index ec016af..04dc4ca 100644 --- a/src/tools/audit-operation-ids.ts +++ b/src/tools/audit-operation-ids.ts @@ -22,6 +22,14 @@ export interface AuditableResource { operations: AuditableOperation[]; } +export type DescriptorVersion = "v2-pcv1" | "v2-pcv2"; + +export interface MissingDescriptor { + command: string; + version: DescriptorVersion; + operationId: string; +} + /** * Return the sorted list of `.` keys whose operationId is * absent from both specs. Purely informational: callers must not treat a @@ -41,3 +49,38 @@ export function auditOperationIds(resources: AuditableResource[]): { unmapped.sort(); return { unmapped }; } + +/** + * Verify that every non-empty operationId generated from the public specs is + * present in the installed code-sample-generator package. An absent id would + * produce a CLI command whose `--code-sample` path fails at runtime. + */ +export function auditDescriptorCompatibility( + resources: AuditableResource[], + hasDescriptor: (version: DescriptorVersion, operationId: string) => boolean, +): { missing: MissingDescriptor[] } { + const missing: MissingDescriptor[] = []; + + for (const resource of resources) { + for (const operation of resource.operations) { + const command = `${resource.sdkName}.${operation.sdkAction}`; + const ids: Array<[DescriptorVersion, string]> = [ + ["v2-pcv1", operation.operationIdV1], + ["v2-pcv2", operation.operationIdV2], + ]; + + for (const [version, operationId] of ids) { + if (operationId && !hasDescriptor(version, operationId)) { + missing.push({ command, version, operationId }); + } + } + } + } + + missing.sort((a, b) => + `${a.command}:${a.version}:${a.operationId}`.localeCompare( + `${b.command}:${b.version}:${b.operationId}`, + ), + ); + return { missing }; +} diff --git a/src/tools/generate.ts b/src/tools/generate.ts index aed4a0a..baa742a 100644 --- a/src/tools/generate.ts +++ b/src/tools/generate.ts @@ -19,6 +19,8 @@ * --spec-url-v1 Override the PC1 OpenAPI spec URL * --spec-file-v2 Use a local PC2 spec file instead of fetching * --spec-file-v1 Use a local PC1 spec file instead of fetching + * --source-sha Pin default spec URLs and generated marker to this + * chargebee/openapi commit (default: newest spec commit) * --offline Use cached specs only; fail if cache is missing * --refresh Force re-fetch even if a cached copy exists * --strict Accepted for backwards compatibility; no longer gates on @@ -29,7 +31,12 @@ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; -import { auditOperationIds } from "./audit-operation-ids.js"; +import { getOperation } from "@chargebee/code-sample-generator/node"; + +import { + auditDescriptorCompatibility, + auditOperationIds, +} from "./audit-operation-ids.js"; import { sectionTitle } from "../lib/help-style.js"; import { specArgument, specExample, operationHelp, type ArgumentHelp } from "./command-help.js"; import { isReadOnlyOperation } from "../lib/api/write-gate.js"; @@ -49,13 +56,40 @@ function has(name: string): boolean { const outDir = resolve(flag("--out") ?? "src/commands/generated"); const cacheDir = resolve(".cache/specs"); +const sourceMarker = join(outDir, ".openapi-source-sha"); + +async function resolveSourceSha(): Promise { + const requested = flag("--source-sha"); + if (requested) return requested; + + const response = await fetch( + "https://api.github.com/repos/chargebee/openapi/commits?path=spec&per_page=1", + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!response.ok) { + throw new Error(`Unable to resolve the latest public spec commit: HTTP ${response.status}`); + } + const commits = (await response.json()) as Array<{ sha?: string }>; + if (!commits[0]?.sha) throw new Error("chargebee/openapi returned no commits touching spec/"); + return commits[0].sha; +} + +const SOURCE_SHA = await resolveSourceSha(); +if (!/^[0-9a-f]{40}$/.test(SOURCE_SHA)) { + throw new Error(`Invalid chargebee/openapi source SHA: ${SOURCE_SHA}`); +} + +const previousSourceSha = existsSync(sourceMarker) + ? readFileSync(sourceMarker, "utf-8").trim() + : undefined; +const DEFAULT_SPEC_BASE = `https://raw.githubusercontent.com/chargebee/openapi/${SOURCE_SHA}/spec`; const SPEC_V2_URL = flag("--spec-url-v2") ?? - "https://raw.githubusercontent.com/chargebee/openapi/refs/heads/main/spec/chargebee_api_v2_pc_v2_spec.json"; + `${DEFAULT_SPEC_BASE}/chargebee_api_v2_pc_v2_spec.json`; const SPEC_V1_URL = flag("--spec-url-v1") ?? - "https://raw.githubusercontent.com/chargebee/openapi/refs/heads/main/spec/chargebee_api_v2_pc_v1_spec.json"; + `${DEFAULT_SPEC_BASE}/chargebee_api_v2_pc_v1_spec.json`; const SPEC_FETCH_TIMEOUT_MS = 120_000; @@ -96,6 +130,14 @@ interface Resource { */ const FORCE_BOTH = new Set([]); +/** + * Resources intentionally omitted from code-sample-generator because their + * operations are not supported consistently across Chargebee SDK languages. + * The CLI command remains usable through the Node SDK, but must not advertise + * a code-sample operation that the installed generator cannot resolve. + */ +const CODE_SAMPLE_UNSUPPORTED_SDK_RESOURCES = new Set(["nonSubscription"]); + /** * Sanity assertions: core operations whose catalog must classify as expected. * A mismatch fails generation loudly so a spec change can't silently mis-gate the @@ -174,7 +216,7 @@ async function loadSpec(label: string, url: string, fileOverride: string | undef const cachePath = join(cacheDir, cacheName); const cached = existsSync(cachePath); - if (cached && !has("--refresh")) { + if (cached && !has("--refresh") && previousSourceSha === SOURCE_SHA) { console.log(`[${label}] using cached spec ${cachePath}`); return JSON.parse(readFileSync(cachePath, "utf-8")); } @@ -250,8 +292,9 @@ async function loadEndpoints(): Promise { const key = `${t[1]}:${normPath(fullPath)}`; const specV2Op = v2Map.get(key); const specV1Op = v1Map.get(key); - const operationIdV2 = specV2Op?.operationId ?? ""; - const operationIdV1 = specV1Op?.operationId ?? ""; + const supportsCodeSamples = !CODE_SAMPLE_UNSUPPORTED_SDK_RESOURCES.has(sdkName); + const operationIdV2 = supportsCodeSamples ? (specV2Op?.operationId ?? "") : ""; + const operationIdV1 = supportsCodeSamples ? (specV1Op?.operationId ?? "") : ""; return { sdkAction: t[0], @@ -521,6 +564,21 @@ if (unmapped.length > 0) { for (const id of unmapped) console.log(` ${id}`); } +const { missing } = auditDescriptorCompatibility( + resources, + (version, operationId) => getOperation(version, operationId) !== undefined, +); +if (missing.length > 0) { + throw new Error( + `The installed @chargebee/code-sample-generator does not contain ${missing.length} ` + + `operation descriptor(s) required by chargebee/openapi@${SOURCE_SHA}:\n ` + + missing + .map(({ command, version, operationId }) => `${command}: ${version}/${operationId}`) + .join("\n ") + + "\nPublish and install a compatible @chargebee/code-sample-generator version before releasing these commands.", + ); +} + const allOps = resources.flatMap((r) => r.operations.map((op) => ({ name: `${r.cliName} ${op.cliCommand}`, catalog: op.catalog }))); const pc1Ops = allOps.filter((o) => o.catalog === "pc1"); const pc2Ops = allOps.filter((o) => o.catalog === "pc2"); @@ -539,6 +597,7 @@ for (const r of resources) { writeFileSync(join(outDir, "registry.ts"), generateRegistry(resources)); writeFileSync(join(outDir, "docs-operations.ts"), generateDocsOperations(resources)); +writeFileSync(join(outDir, ".openapi-source-sha"), `${SOURCE_SHA}\n`); const withSamples = totalOps - unmapped.length; console.log(`Done. Generated ${resources.length} resource files + registry.ts + docs-operations.ts`); console.log(`Code samples available for ~${withSamples}/${totalOps} operations (${unmapped.length} unmapped in specs).`); From a2db89c3d4185adaf0a4bd610f0279354b4e0682 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Tue, 13 Oct 2026 10:29:43 +0530 Subject: [PATCH 2/6] test(openapi): cover descriptor sorting --- src/tests/unit/tools/audit-operation-ids.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tests/unit/tools/audit-operation-ids.test.ts b/src/tests/unit/tools/audit-operation-ids.test.ts index 226eed2..69c5e05 100644 --- a/src/tests/unit/tools/audit-operation-ids.test.ts +++ b/src/tests/unit/tools/audit-operation-ids.test.ts @@ -71,11 +71,16 @@ describe("auditDescriptorCompatibility", () => { ]; it("reports every mapped operation absent from the installed descriptors", () => { - const installed = new Set(["v2-pcv1:list_customers", "v2-pcv2:create_a_customer"]); + const installed = new Set(["v2-pcv1:list_customers"]); const hasDescriptor = (version: DescriptorVersion, operationId: string) => installed.has(`${version}:${operationId}`); expect(auditDescriptorCompatibility(resources, hasDescriptor).missing).toEqual([ + { + command: "customer.create", + version: "v2-pcv2", + operationId: "create_a_customer", + }, { command: "customer.list", version: "v2-pcv2", From cdebb24c375c97d15aee04ce0ec38e7a6747de3b Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Tue, 13 Oct 2026 10:49:17 +0530 Subject: [PATCH 3/6] fix(openapi): preserve cached spec provenance --- .github/workflows/reconcile-openapi.yml | 6 +- src/tools/generate.ts | 112 ++++++++++++++++++++---- 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/.github/workflows/reconcile-openapi.yml b/.github/workflows/reconcile-openapi.yml index 366f145..1cdd88b 100644 --- a/.github/workflows/reconcile-openapi.yml +++ b/.github/workflows/reconcile-openapi.yml @@ -125,7 +125,9 @@ jobs: SPEC_SHA: ${{ steps.inputs.outputs.spec_sha }} run: | FIRST_HASH=$(find src/commands/generated -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d ' ' -f 1) - bun run src/tools/generate.ts --offline --source-sha "$SPEC_SHA" + # Omit --source-sha deliberately: offline generation must resolve the + # immutable revision from the cache provenance written above. + bun run src/tools/generate.ts --offline SECOND_HASH=$(find src/commands/generated -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d ' ' -f 1) if [ "$FIRST_HASH" != "$SECOND_HASH" ]; then echo "::error::CLI command generation is not deterministic" @@ -170,6 +172,8 @@ jobs: private-key: ${{ secrets.CLI_AUTOMATION_APP_PRIVATE_KEY }} owner: chargebee repositories: cli + permission-contents: write + permission-pull-requests: write - name: Create or update reconciliation PR if: steps.changes.outputs.changed == 'true' diff --git a/src/tools/generate.ts b/src/tools/generate.ts index baa742a..984fc4e 100644 --- a/src/tools/generate.ts +++ b/src/tools/generate.ts @@ -57,11 +57,27 @@ function has(name: string): boolean { const outDir = resolve(flag("--out") ?? "src/commands/generated"); const cacheDir = resolve(".cache/specs"); const sourceMarker = join(outDir, ".openapi-source-sha"); +const cacheSourceMarker = join(cacheDir, ".openapi-source-sha"); + +function readSourceMarker(path: string): string | undefined { + if (!existsSync(path)) return undefined; + const value = readFileSync(path, "utf-8").trim(); + return /^[0-9a-f]{40}$/.test(value) ? value : undefined; +} + +const cachedSourceSha = readSourceMarker(cacheSourceMarker); async function resolveSourceSha(): Promise { const requested = flag("--source-sha"); if (requested) return requested; + if (has("--offline")) { + if (cachedSourceSha) return cachedSourceSha; + throw new Error( + "--offline requires --source-sha or a cached spec revision. Run once online to populate .cache/specs/.", + ); + } + const response = await fetch( "https://api.github.com/repos/chargebee/openapi/commits?path=spec&per_page=1", { headers: { Accept: "application/vnd.github+json" } }, @@ -79,16 +95,15 @@ if (!/^[0-9a-f]{40}$/.test(SOURCE_SHA)) { throw new Error(`Invalid chargebee/openapi source SHA: ${SOURCE_SHA}`); } -const previousSourceSha = existsSync(sourceMarker) - ? readFileSync(sourceMarker, "utf-8").trim() - : undefined; const DEFAULT_SPEC_BASE = `https://raw.githubusercontent.com/chargebee/openapi/${SOURCE_SHA}/spec`; +const specUrlV2Override = flag("--spec-url-v2"); +const specUrlV1Override = flag("--spec-url-v1"); const SPEC_V2_URL = - flag("--spec-url-v2") ?? + specUrlV2Override ?? `${DEFAULT_SPEC_BASE}/chargebee_api_v2_pc_v2_spec.json`; const SPEC_V1_URL = - flag("--spec-url-v1") ?? + specUrlV1Override ?? `${DEFAULT_SPEC_BASE}/chargebee_api_v2_pc_v1_spec.json`; const SPEC_FETCH_TIMEOUT_MS = 120_000; @@ -206,30 +221,59 @@ async function fetchText(url: string): Promise { } } -/** Load a spec from a file override, the cache, or the network (cached on fetch). */ -async function loadSpec(label: string, url: string, fileOverride: string | undefined, cacheName: string): Promise> { +interface LoadedSpec { + spec: Record; + fetchedToCache: boolean; +} + +/** Load a spec from a file override, its revision-scoped cache, or the network. */ +async function loadSpec( + label: string, + url: string, + fileOverride: string | undefined, + cacheName: string, + useCanonicalCache: boolean, +): Promise { if (fileOverride) { console.log(`[${label}] using local file ${fileOverride}`); - return JSON.parse(readFileSync(resolve(fileOverride), "utf-8")); + return { + spec: JSON.parse(readFileSync(resolve(fileOverride), "utf-8")), + fetchedToCache: false, + }; } - const cachePath = join(cacheDir, cacheName); - const cached = existsSync(cachePath); + // The revision directory is cache provenance: files from different immutable + // public commits can never be mistaken for each other after an interrupted run. + const cachePath = join(cacheDir, SOURCE_SHA, cacheName); + const cached = useCanonicalCache && existsSync(cachePath); - if (cached && !has("--refresh") && previousSourceSha === SOURCE_SHA) { + if (cached && !has("--refresh")) { console.log(`[${label}] using cached spec ${cachePath}`); - return JSON.parse(readFileSync(cachePath, "utf-8")); + return { + spec: JSON.parse(readFileSync(cachePath, "utf-8")), + fetchedToCache: false, + }; } if (has("--offline")) { - throw new Error(`[${label}] --offline set but no cached spec at ${cachePath}. Run once online to populate the cache.`); + const provenance = cachedSourceSha ?? "unknown"; + throw new Error( + `[${label}] --offline cannot use ${cachePath} for chargebee/openapi@${SOURCE_SHA} ` + + `(cached revision: ${provenance}). Run once online to populate that revision.`, + ); } console.log(`[${label}] fetching ${url}`); const text = await fetchText(url); - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cachePath, text); - return JSON.parse(text); + const spec = JSON.parse(text) as Record; + if (useCanonicalCache) { + mkdirSync(join(cacheDir, SOURCE_SHA), { recursive: true }); + writeFileSync(cachePath, text); + } + return { + spec, + fetchedToCache: useCanonicalCache, + }; } interface SpecOp { @@ -270,10 +314,40 @@ function humanizeAction(action: string): string { } async function loadEndpoints(): Promise { - const [specV2, specV1] = await Promise.all([ - loadSpec("v2-pc2", SPEC_V2_URL, flag("--spec-file-v2"), "chargebee_api_v2_pc_v2_spec.json"), - loadSpec("v2-pc1", SPEC_V1_URL, flag("--spec-file-v1"), "chargebee_api_v2_pc_v1_spec.json"), + const specFileV2 = flag("--spec-file-v2"); + const specFileV1 = flag("--spec-file-v1"); + const canonicalV2 = !specFileV2 && !specUrlV2Override; + const canonicalV1 = !specFileV1 && !specUrlV1Override; + + const [loadedV2, loadedV1] = await Promise.all([ + loadSpec( + "v2-pc2", + SPEC_V2_URL, + specFileV2, + "chargebee_api_v2_pc_v2_spec.json", + canonicalV2, + ), + loadSpec( + "v2-pc1", + SPEC_V1_URL, + specFileV1, + "chargebee_api_v2_pc_v1_spec.json", + canonicalV1, + ), ]); + if ( + canonicalV2 && + canonicalV1 && + (loadedV2.fetchedToCache || loadedV1.fetchedToCache || cachedSourceSha !== SOURCE_SHA) + ) { + // Point offline-without-a-SHA at this revision only after both canonical + // specs loaded successfully. File and URL overrides never update it. + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheSourceMarker, `${SOURCE_SHA}\n`); + } + + const specV2 = loadedV2.spec; + const specV1 = loadedV1.spec; const v2Map = buildSpecMap(specV2); const v1Map = buildSpecMap(specV1); console.log(`Loaded operationIds: PC2=${v2Map.size}, PC1=${v1Map.size}`); From 02ad6d9f8ec378fb19bd805dc6a8ba665a083f16 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Tue, 13 Oct 2026 14:10:46 +0530 Subject: [PATCH 4/6] ci: use distributed gitflow app credentials --- .github/workflows/reconcile-openapi.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/reconcile-openapi.yml b/.github/workflows/reconcile-openapi.yml index 1cdd88b..2a146aa 100644 --- a/.github/workflows/reconcile-openapi.yml +++ b/.github/workflows/reconcile-openapi.yml @@ -163,23 +163,19 @@ jobs: echo "CLI already matches the latest public spec and generator package." fi - - name: Create pull-request token + - name: Get GitHub App installation token if: steps.changes.outputs.changed == 'true' id: app-token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + uses: chargebee/cb-cicd-pipelines/.github/actions/get-github-app-installation-access-token@v4.45.0 with: - app-id: ${{ secrets.CLI_AUTOMATION_APP_ID }} - private-key: ${{ secrets.CLI_AUTOMATION_APP_PRIVATE_KEY }} - owner: chargebee - repositories: cli - permission-contents: write - permission-pull-requests: write + GH_APP_CREDENTIALS_TOKEN: ${{ secrets.DISTRIBUTED_GITFLOW_GH_APP_CREDENTIALS_TOKEN }} + REPOSITORY_NAME: ${{ github.event.repository.name }} - name: Create or update reconciliation PR if: steps.changes.outputs.changed == 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: - token: ${{ steps.app-token.outputs.token }} + token: ${{ steps.app-token.outputs.INSTALLATION_ACCESS_TOKEN }} base: main branch: automation/reconcile-openapi delete-branch: true From bdeb97e6cbe7ae494aee0cbb5846f2314f9de770 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Tue, 13 Oct 2026 17:07:26 +0530 Subject: [PATCH 5/6] ci: pin installation token helper --- .github/workflows/reconcile-openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reconcile-openapi.yml b/.github/workflows/reconcile-openapi.yml index 2a146aa..72b8860 100644 --- a/.github/workflows/reconcile-openapi.yml +++ b/.github/workflows/reconcile-openapi.yml @@ -166,7 +166,7 @@ jobs: - name: Get GitHub App installation token if: steps.changes.outputs.changed == 'true' id: app-token - uses: chargebee/cb-cicd-pipelines/.github/actions/get-github-app-installation-access-token@v4.45.0 + uses: chargebee/cb-cicd-pipelines/.github/actions/get-github-app-installation-access-token@fa354e0c48c2f6da74a892b1f4438cd0b5e78556 # v4.45.0 with: GH_APP_CREDENTIALS_TOKEN: ${{ secrets.DISTRIBUTED_GITFLOW_GH_APP_CREDENTIALS_TOKEN }} REPOSITORY_NAME: ${{ github.event.repository.name }} From 5c8b46b0b3d4d4be4f602c8d9a26b8207dd079d1 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Tue, 13 Oct 2026 17:09:44 +0530 Subject: [PATCH 6/6] ci: reuse SDK bot for reconciliation PRs --- .github/workflows/reconcile-openapi.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/reconcile-openapi.yml b/.github/workflows/reconcile-openapi.yml index 72b8860..8d4baf2 100644 --- a/.github/workflows/reconcile-openapi.yml +++ b/.github/workflows/reconcile-openapi.yml @@ -163,19 +163,23 @@ jobs: echo "CLI already matches the latest public spec and generator package." fi - - name: Get GitHub App installation token + - name: Create pull-request token if: steps.changes.outputs.changed == 'true' id: app-token - uses: chargebee/cb-cicd-pipelines/.github/actions/get-github-app-installation-access-token@fa354e0c48c2f6da74a892b1f4438cd0b5e78556 # v4.45.0 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: - GH_APP_CREDENTIALS_TOKEN: ${{ secrets.DISTRIBUTED_GITFLOW_GH_APP_CREDENTIALS_TOKEN }} - REPOSITORY_NAME: ${{ github.event.repository.name }} + app-id: ${{ secrets.CB_SDK_BOT_APP_ID }} + private-key: ${{ secrets.CB_SDK_BOT_PEM_KEY }} + owner: chargebee + repositories: cli + permission-contents: write + permission-pull-requests: write - name: Create or update reconciliation PR if: steps.changes.outputs.changed == 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: - token: ${{ steps.app-token.outputs.INSTALLATION_ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} base: main branch: automation/reconcile-openapi delete-branch: true