Sync OpenAPI spec #1163
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Sync OpenAPI spec | |
| # Syncs the live SpaceMolt v2 OpenAPI spec. When the spec changes, | |
| # regenerates types + command registry, runs tests, bumps the client patch | |
| # version, commits to main, and tags a new release (which triggers | |
| # release.yml to build and publish platform binaries). | |
| # | |
| # Triggers: | |
| # - repository_dispatch (gameserver-deployed): fired by the gameserver's | |
| # deploy workflow right after it kicks off a Render deploy. Render deploys | |
| # are asynchronous, so the sync first polls until the dispatched version | |
| # is actually live. | |
| # - schedule: backstop in case a dispatch is missed. GitHub Actions cron is | |
| # heavily throttled in practice — observed intervals of 80-100 minutes | |
| # despite the 30-minute schedule. | |
| # - workflow_dispatch: manual override via the Actions tab. | |
| # | |
| # Cost when nothing changed: ~10s (curl + diff + exit). Cost when a change | |
| # is detected: ~2 minutes (full test suite + commit/push). | |
| on: | |
| repository_dispatch: | |
| types: [gameserver-deployed] | |
| schedule: | |
| # Every 30 minutes (best-effort; see note above). | |
| - cron: '*/30 * * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| # Required for the `gh workflow run release.yml` dispatch step; without it | |
| # the dispatch fails with HTTP 403 and no release binaries get built. | |
| actions: write | |
| concurrency: | |
| # Never let two sync runs race; a queued run waits, doesn't pile up. | |
| group: sync-spec | |
| cancel-in-progress: false | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # Use the default GITHUB_TOKEN; the workflow has contents:write. | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install dependencies | |
| run: bun install | |
| - name: Confirm dispatched gameserver version is live | |
| if: github.event_name == 'repository_dispatch' && github.event.client_payload.version != null | |
| run: | | |
| # The gameserver's on-box deploy fires this dispatch only *after* | |
| # /health confirms the new version is serving, so the public spec is | |
| # essentially always already live — the first probe below matches. We | |
| # keep a short bounded confirmation (not the old ~20-min poll, a leftover | |
| # from the async-Render era) to absorb brief edge/CDN propagation. 30s | |
| # spacing stays under the spec endpoint's rate limit. | |
| TARGET="${{ github.event.client_payload.version }}" | |
| TARGET="${TARGET#v}" | |
| echo "Confirming gameserver v${TARGET} is live..." | |
| for i in $(seq 1 5); do | |
| LIVE=$(curl -sf https://game.spacemolt.com/api/v2/openapi.json | jq -r '.info["x-gameserver-version"] // empty' 2>/dev/null || true) | |
| LIVE="${LIVE#v}" | |
| if [ "$LIVE" = "$TARGET" ]; then | |
| echo "Live version is v${LIVE} — proceeding with sync." | |
| exit 0 | |
| fi | |
| echo "Attempt ${i}/5: live=v${LIVE:-unknown}, want=v${TARGET}. Sleeping 30s." | |
| sleep 30 | |
| done | |
| echo "v${TARGET} not confirmed live yet; syncing whatever is live. The cron backstop will catch any miss." | |
| - name: Fetch live spec | |
| run: bun run fetch-spec | |
| - name: Diff against tracked spec | |
| id: diff | |
| run: | | |
| # The server stamps info.x-gameserver-version into the spec on every | |
| # deploy, even when the API surface is identical. A raw file diff | |
| # would therefore cut a client release for every gameserver deploy. | |
| # Compare the specs with that field stripped so only real API | |
| # changes trigger a release. Nothing in the client consumes | |
| # x-gameserver-version, so letting it go stale in the tracked spec | |
| # is harmless; it catches up on the next real change. | |
| git show HEAD:openapi.json > /tmp/tracked-spec.json | |
| if bun -e " | |
| const fs = require('fs'); | |
| const sort = (x) => Array.isArray(x) ? x.map(sort) | |
| : x && typeof x === 'object' | |
| ? Object.fromEntries(Object.keys(x).sort().map(k => [k, sort(x[k])])) | |
| : x; | |
| const norm = (f) => { | |
| const spec = JSON.parse(fs.readFileSync(f, 'utf-8')); | |
| if (spec.info) delete spec.info['x-gameserver-version']; | |
| return JSON.stringify(sort(spec)); | |
| }; | |
| process.exit(norm('/tmp/tracked-spec.json') === norm('openapi.json') ? 0 : 1); | |
| "; then | |
| echo "Spec is unchanged (ignoring x-gameserver-version)." | |
| # Discard the version-only delta so the tree stays clean. | |
| git checkout -- openapi.json | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Spec changed." | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Summarize spec diff | |
| if: steps.diff.outputs.changed == 'true' | |
| id: summary | |
| run: | | |
| # Compare the OLD tracked spec (HEAD) with the NEW just-fetched one. | |
| # Produces lists of added/removed paths and the new info.version. | |
| git show HEAD:openapi.json > /tmp/old-spec.json | |
| bun -e " | |
| const oldSpec = JSON.parse(require('fs').readFileSync('/tmp/old-spec.json','utf-8')); | |
| const newSpec = JSON.parse(require('fs').readFileSync('openapi.json','utf-8')); | |
| const oldPaths = new Set(Object.keys(oldSpec.paths)); | |
| const newPaths = new Set(Object.keys(newSpec.paths)); | |
| const added = [...newPaths].filter(p => !oldPaths.has(p)).sort(); | |
| const removed = [...oldPaths].filter(p => !newPaths.has(p)).sort(); | |
| const fs = require('fs'); | |
| let body = ''; | |
| body += 'Old spec version: ' + (oldSpec.info?.version || 'unknown') + '\n'; | |
| body += 'New spec version: ' + (newSpec.info?.version || 'unknown') + '\n'; | |
| body += 'Paths: ' + oldPaths.size + ' -> ' + newPaths.size + '\n'; | |
| if (added.length) body += '\nAdded paths (' + added.length + '):\n' + added.map(p => ' + ' + p).join('\n') + '\n'; | |
| if (removed.length) body += '\nRemoved paths (' + removed.length + '):\n' + removed.map(p => ' - ' + p).join('\n') + '\n'; | |
| if (!added.length && !removed.length) body += '\n(Schema-only change; no paths added or removed.)\n'; | |
| fs.writeFileSync('/tmp/spec-summary.txt', body); | |
| " | |
| cat /tmp/spec-summary.txt | |
| # Stash for the commit message step. | |
| { | |
| echo 'summary<<SPEC_SUMMARY_EOF' | |
| cat /tmp/spec-summary.txt | |
| echo 'SPEC_SUMMARY_EOF' | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Regenerate types and command registry | |
| if: steps.diff.outputs.changed == 'true' | |
| run: bun run generate | |
| - name: Typecheck | |
| if: steps.diff.outputs.changed == 'true' | |
| run: bun run typecheck | |
| - name: Test | |
| if: steps.diff.outputs.changed == 'true' | |
| run: bun test | |
| - name: Bump patch version | |
| if: steps.diff.outputs.changed == 'true' | |
| id: version | |
| run: | | |
| # Pick the next FREE version rather than blindly bumping package.json. | |
| # The base is the higher of the tracked package.json version and the | |
| # highest existing release tag; the patch is then advanced until the | |
| # matching tag is unused. This stops the bot from ever reusing or | |
| # clobbering a version when a feature PR (or a prior run) has already | |
| # advanced package.json or pushed a tag — the cause of the v1.4.50 | |
| # collision. Only the patch component moves; major/minor are never | |
| # touched automatically. fetch-depth: 0 (above) ensures all tags are | |
| # present locally for this comparison. | |
| OLD=$(node -p "require('./package.json').version") | |
| TAGS=$(git tag --list 'v*.*.*' | sed 's/^v//') | |
| NEW=$(TAGS="$TAGS" node -e ' | |
| const pkg = require("./package.json").version; | |
| const tags = (process.env.TAGS || "").split("\n").filter(Boolean); | |
| const cmp = (a, b) => { | |
| const pa = a.split(".").map(Number), pb = b.split(".").map(Number); | |
| for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] - pb[i]; } | |
| return 0; | |
| }; | |
| const taken = new Set(tags); | |
| let base = pkg; | |
| for (const t of tags) if (cmp(t, base) > 0) base = t; | |
| const v = base.split(".").map(Number); | |
| do { v[2]++; } while (taken.has(v.join("."))); | |
| console.log(v.join(".")); | |
| ') | |
| echo "Bumping $OLD -> $NEW (next free tag)" | |
| node -e " | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('package.json','utf-8')); | |
| pkg.version = '$NEW'; | |
| fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); | |
| " | |
| echo "OLD=$OLD" >> "$GITHUB_OUTPUT" | |
| echo "NEW=$NEW" >> "$GITHUB_OUTPUT" | |
| - name: Commit and tag | |
| if: steps.diff.outputs.changed == 'true' | |
| env: | |
| NEW_VERSION: ${{ steps.version.outputs.NEW }} | |
| SPEC_SUMMARY: ${{ steps.summary.outputs.summary }} | |
| run: | | |
| git config user.name "SpaceMolt DevTeam" | |
| git config user.email "devteam@spacemolt.com" | |
| # Stage the entire generated tree, not a hand-picked subset: `bun run | |
| # generate` rewrites all of src/generated/ (sdk.gen.ts, client/*, core/*, | |
| # etc.), and the SDK surface now ships in the npm package, so any of those | |
| # files can legitimately change on a spec sync. Listing files individually | |
| # silently dropped those regenerated outputs and let them drift. | |
| git add openapi.json src/commands.ts src/generated package.json | |
| # If only auto-generated files would change but git add staged | |
| # nothing (shouldn't happen given we already saw a diff, but | |
| # belt-and-suspenders), bail out without an empty commit. | |
| if git diff --staged --quiet; then | |
| echo "No staged changes after regen. Skipping commit." | |
| exit 0 | |
| fi | |
| # Heredoc keeps the multi-line summary intact in the commit body. | |
| git commit -m "chore: sync OpenAPI spec (v${NEW_VERSION})" -m "$SPEC_SUMMARY" -m "Auto-generated by .github/workflows/sync-spec.yml. | |
| Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" | |
| git push origin main | |
| git tag -a "v${NEW_VERSION}" -m "v${NEW_VERSION}" | |
| git push origin "v${NEW_VERSION}" | |
| - name: Trigger release workflow | |
| if: steps.diff.outputs.changed == 'true' | |
| # Tags pushed using GITHUB_TOKEN do NOT trigger other workflows | |
| # (GitHub anti-recursion safeguard). Dispatch release.yml explicitly. | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NEW_VERSION: ${{ steps.version.outputs.NEW }} | |
| run: | | |
| gh workflow run release.yml --ref main -f tag="v${NEW_VERSION}" | |
| - name: Summary | |
| if: always() | |
| run: | | |
| if [ "${{ steps.diff.outputs.changed }}" = "true" ]; then | |
| echo "### Spec sync: v${{ steps.version.outputs.OLD }} -> v${{ steps.version.outputs.NEW }}" >> $GITHUB_STEP_SUMMARY | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| cat /tmp/spec-summary.txt >> $GITHUB_STEP_SUMMARY | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "Spec unchanged; no action taken." >> $GITHUB_STEP_SUMMARY | |
| fi |