Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
206 changes: 206 additions & 0 deletions .github/workflows/reconcile-openapi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
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:-<empty>}"
exit 1
fi
if [[ ! "$DISPATCH_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Invalid dispatched OpenAPI SHA: ${DISPATCH_SOURCE_SHA:-<empty>}"
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:-<empty>}"
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:-<empty>}"
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)
# 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"
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.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.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 }}).
10 changes: 4 additions & 6 deletions .github/workflows/source-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/commands/generated/.openapi-source-sha
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
62be342fa9029a5a4a80826fb63b8d1ca6dfd8c5
4 changes: 2 additions & 2 deletions src/commands/generated/docs-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,8 +1312,8 @@ export const docsOperationIds: Record<string, Record<string, { v1: string; v2: s
},
"non-subscription": {
"process-receipt": {
"v1": "one_time_purchase",
"v2": "one_time_purchase"
"v1": "",
"v2": ""
}
},
"offer-event": {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/generated/non_subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export function registerNonSubscription(parent: Command): void {
.addHelpText("after", "\n\u001b[1mDOCUMENTATION\u001b[0m\n chargebee docs non-subscription process-receipt\n")
.action(async (id: string | undefined, json: string | undefined, opts: { data?: string[]; codeSample?: string; pcVersion?: string }, command: Command) => {
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");
Expand Down
7 changes: 7 additions & 0 deletions src/tests/integration/generated-operation-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
44 changes: 43 additions & 1 deletion src/tests/unit/tools/audit-operation-ids.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -52,3 +57,40 @@ 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"]);
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",
operationId: "list_customers",
},
]);
});

it("ignores empty ids because that catalog has no code sample", () => {
const hasDescriptor = () => true;
expect(auditDescriptorCompatibility(resources, hasDescriptor).missing).toEqual([]);
});
});
43 changes: 43 additions & 0 deletions src/tools/audit-operation-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sdkName>.<sdkAction>` keys whose operationId is
* absent from both specs. Purely informational: callers must not treat a
Expand All @@ -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 };
}
Loading
Loading