Skip to content

Sync OpenAPI spec #1163

Sync OpenAPI spec

Sync OpenAPI spec #1163

Workflow file for this run

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