Skip to content

feat(ci): make api-sync deterministic, drop claude-code-action - #22

Merged
ericviana merged 2 commits into
mainfrom
eric/deterministic-sync
Jul 27, 2026
Merged

feat(ci): make api-sync deterministic, drop claude-code-action#22
ericviana merged 2 commits into
mainfrom
eric/deterministic-sync

Conversation

@ericviana

Copy link
Copy Markdown
Member

Summary

Replaces the LLM-driven api-sync.yml (Claude via claude-code-action, gated on CLAUDE_CODE_OAUTH_TOKEN) with a pure, reviewable script pipeline in scripts/api-sync/. No LLM, no OAuth token, in the loop at all.

  • parse-changelog.ts — parses blindpay-v2's spec-diff.ts markdown into structured events; fails loudly on anything it doesn't recognize instead of dropping it.
  • known-resources.ts — the generator's entire "what can I touch" map (path -> resource -> function name). A path not in this map is unmappable by construction.
  • classify.ts — splits events into applicable (an additive optional field on the REQUEST body of a known create/update path — the one pattern every historical additive api-sync PR here actually used, e.g. feat: sync CLI with API changes #6) and needsHuman (everything else: removed fields, enum changes, response-only field changes, new/removed endpoints/methods/schemas).
  • apply.ts — anchor-based, idempotent source patches to resources.ts, index.ts, and schema.ts for each applicable field.
  • version-bump.ts — minor if any endpoint/method/enum value was added or removed, patch otherwise. Scripted, not guessed.
  • generate.ts — orchestrates the above and emits a SUMMARY_JSON line the workflow reads.

api-sync.yml now: fetches the changelog, runs the generator, runs this repo's existing ci.yml commands (typecheck/lint/test/build) against the result, commits, opens/updates a PR — and only calls gh pr merge --auto --squash when the generator reports nothing needed a human. If a changelog contains only unmappable changes, no PR is opened (there's no diff); a plain issue is opened instead so it isn't silently lost.

Also flipped the repo's allow_auto_merge setting on via the GitHub API (was off, required for --auto to work), and added scripts/ to bun run typecheck/lint so the generator itself is covered by CI.

Determinism proof

Ran the generator twice from the same changelog fixture against two independent copies of this repo:

1d78dc1c036346952fb260cbfa4309d501c348146b309c051986270132112d0c  repo1/src/commands/resources.ts
bed9aa5611cd5bfcfce5c1fefb070689532686d5f0da7b144b11e8710748f477  repo1/src/index.ts
b7526eb022559f45024f2f90a365dd57ce9552d584ba46cd16707d48e0bb9025  repo1/src/commands/schema.ts
5d79b9016e0d628dc72412f461ab34cacdc6dd3885ba4c1f7982856a99b7a1e0  repo1/package.json
1d78dc1c036346952fb260cbfa4309d501c348146b309c051986270132112d0c  repo2/src/commands/resources.ts
bed9aa5611cd5bfcfce5c1fefb070689532686d5f0da7b144b11e8710748f477  repo2/src/index.ts
b7526eb022559f45024f2f90a365dd57ce9552d584ba46cd16707d48e0bb9025  repo2/src/commands/schema.ts
5d79b9016e0d628dc72412f461ab34cacdc6dd3885ba4c1f7982856a99b7a1e0  repo2/package.json

Byte-identical. Re-applying the generator a second time against already-patched output is also a no-op (see the idempotency tests in scripts/api-sync/__tests__/apply.test.ts).

Equivalence proof

Fetched the real parent commit of historical PR #6 (ba11caa) and its first commit (125c09b, the additive swift_ifsc_branch_code field on createBankAccount). Built a changelog fixture describing that exact spec change and ran the generator against the ba11caa baseline. Diff against the real merged commit:

--- resources.ts (real PR #6)                    --- resources.ts (generator)
+ swiftIfscBranchCode?: string                    + swiftIfscBranchCode?: string   (same anchor)
+ swift_ifsc_branch_code: options.… ?? null,       + if (options.swiftIfscBranchCode !== undefined)
  (inline in the body object literal)                 body.swift_ifsc_branch_code = options.…
                                                        (statement before the apiPost call)

--- index.ts
+ .option('--swift-ifsc-branch-code <code>',       + .option('--swift-ifsc-branch-code <value>',
    'SWIFT/IFSC branch code (international_swift     'Swift Ifsc Branch Code (added by api-sync)')
     accounts)')

Same flag name, same anchor point, functionally equivalent body handling (the generator omits the key entirely instead of sending null when the flag isn't passed — arguably better, and matches the newer partial-update convention already used elsewhere in this file). The only differences are the auto-generated help text (cosmetic; a human can refine it in review) and that the real PR still bumped the old constants.ts-based version scheme, which the very next commit in that same PR replaced with the package.json scheme this generator uses. Both diffs are explained line-by-line, not hidden.

Scope, honestly

The CAN-express surface is intentionally narrow: additive optional fields on the request body of a resource already in known-resources.ts. Field removals, enum-only changes, and response-field changes are routed to needs-human even though earlier LLM-driven syncs sometimes handled them — see the comment block at the top of classify.ts for why each isn't safe to script deterministically today (no stable anchor for enum help text, "is this interesting enough for a column" is a judgment call, removing a flag can break someone's script). New resources/endpoints always need hand-written command wiring and are never touched.

v2-side proposals (not applied — I don't touch blindpay-v2)

  1. sdk-sync.yml only pushes changelog.md (markdown) and the OLD baseline spec to downstream SDK repos, not the NEW spec JSON (unlike mcp-sync.yml, which does push full JSON to blindpay-mcp). Pushing the new spec JSON alongside the changelog would let this generator diff structured JSON directly instead of parsing markdown, which is more robust long-term and would let future generators (here or in other SDKs) go beyond the field/enum surface safely.
  2. scripts/spec-diff.ts could optionally emit a parallel changelog.json (structured events) alongside the markdown, generated from the same diff pass — removes the need for every downstream repo to hand-roll a markdown parser like parse-changelog.ts here.

Test plan

  • bun run typecheck, bun run lint, bun test, bun run build all pass on this branch
  • Determinism: two independent runs of the generator on the same input hash-identical (above)
  • Equivalence: regenerating PR feat: sync CLI with API changes #6's historical change reproduces it, diff explained line-by-line (above)
  • scripts/api-sync/__tests__/ covers the parser (recognized formats + fail-loud on unrecognized ones), the classifier (applicable vs needs-human for every event kind), and apply.ts (real repo source, plus idempotency)
  • First live repository_dispatch from blindpay-v2 after merge, to confirm the end-to-end flow against a real changelog

Replace the LLM-driven api-sync workflow with a pure script pipeline
(scripts/api-sync/) that parses blindpay-v2's changelog, applies only
the additive request-body fields it can safely express on known
resource paths, and routes everything else (new endpoints, removed
fields, enum changes, response-only changes) to a needs-human PR or
issue instead of guessing. Auto-merge only fires when nothing needed a
human. Also enables allow_auto_merge on the repo and lints/typechecks
scripts/ alongside src/.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
applySchemaField anchored the new FieldDef's indentation on the closing bracket
line of the fields array, which this codebase indents 2 spaces shallower than the
array elements, so every generated field landed visibly under-indented. The anchor
now takes its indent from the last sibling element, and the test asserts the
inserted line matches a sibling's indentation instead of only checking the
substring exists.

Found by the adversarial gate reviewing this PR.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
@ericviana
ericviana merged commit 0ac568b into main Jul 27, 2026
1 check passed
@ericviana
ericviana deleted the eric/deterministic-sync branch July 27, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant