name: OpenAPI Drift
on:
schedule:
- cron: "17 3 * * 1"
workflow_dispatch:
concurrency:
group: openapi-drift-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
issues: write
jobs:
detect-drift:
name: Detect OpenAPI Drift
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Fetch latest upstream OpenAPI spec
run: curl --fail --show-error -L "https://openrouter.ai/openapi.json" -o /tmp/openapi-latest.json
- name: Compare upstream spec against tracked baseline
id: compare
run: |
python3 scripts/openapi_drift.py compare \
--baseline specs/openrouter/openapi-baseline.json \
--candidate /tmp/openapi-latest.json \
--source-url https://openrouter.ai/openapi.json \
--baseline-label "tracked baseline" \
--candidate-label "latest upstream" \
--report-md /tmp/openapi-drift-report.md \
--report-json /tmp/openapi-drift-report.json \
--candidate-operations /tmp/openapi-latest.operations.json \
--github-output "$GITHUB_OUTPUT" \
--step-summary "$GITHUB_STEP_SUMMARY"
- name: Upload drift report artifacts
uses: actions/upload-artifact@v4
with:
name: openapi-drift-report
path: |
/tmp/openapi-drift-report.md
/tmp/openapi-drift-report.json
/tmp/openapi-latest.operations.json
- name: Open or refresh follow-up issue
if: github.event_name == 'schedule' && steps.compare.outputs.has_actionable_drift == 'true'
uses: actions/github-script@v7
env:
REPORT_PATH: /tmp/openapi-drift-report.md
with:
script: |
const fs = require('fs');
const owner = context.repo.owner;
const repo = context.repo.repo;
const title = 'chore: review latest OpenRouter OpenAPI drift';
const report = fs.readFileSync(process.env.REPORT_PATH, 'utf8');
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const policyUrl = `${context.serverUrl}/${owner}/${repo}/blob/main/docs/policies/compatibility-update-policy.md`;
const maxBodyLength = 60000;
const truncatedReport = report.length > maxBodyLength
? `${report.slice(0, maxBodyLength)}\n\n... report truncated; see workflow artifact for the full diff ...`
: report;
const body = [
'## Summary',
'Detected actionable upstream OpenAPI drift against the tracked baseline.',
'',
'## Trigger',
'- Source: weekly OpenAPI drift workflow',
`- Workflow run: ${runUrl}`,
'',
'## Expected Repo Follow-Up',
'- [ ] Review the drift report and decide whether the change is accepted, deferred, or out of scope',
'- [ ] Update `docs/operations/official-endpoint-test-matrix.md` if the reviewed operation surface changed',
'- [ ] Update `CHANGELOG.md` if a user-visible repo change lands in response',
'- [ ] Update `MIGRATION.md` if canonical usage or compatibility bridges changed',
'- [ ] Refresh the tracked baseline with `just openapi-refresh-baseline` if the upstream change is accepted',
'',
`Policy: ${policyUrl}`,
'',
'This issue is maintained automatically by `.github/workflows/openapi-drift.yml`.',
'For manual reports that are not driven by spec drift, use `.github/ISSUE_TEMPLATE/upstream-compatibility-update.md`.',
'',
'## Drift Report',
truncatedReport,
].join('\n');
const issues = await github.paginate(
github.rest.issues.listForRepo,
{
owner,
repo,
state: 'open',
per_page: 100,
},
);
const existing = issues.find(
(issue) => !issue.pull_request && issue.title === title,
);
if (existing) {
await github.rest.issues.update({
owner,
repo,
issue_number: existing.number,
body,
labels: ['ci', 'docs', 'migration'],
});
return;
}
await github.rest.issues.create({
owner,
repo,
title,
body,
labels: ['ci', 'docs', 'migration'],
});