diff --git a/.github/workflows/openapi-breaking-change.yml b/.github/workflows/openapi-breaking-change.yml new file mode 100644 index 000000000..eee471375 --- /dev/null +++ b/.github/workflows/openapi-breaking-change.yml @@ -0,0 +1,178 @@ +name: OpenAPI Breaking Change Detection + +on: + pull_request: + paths: + - "BackEnd/src/**" + - "BackEnd/package.json" + - ".github/workflows/openapi-breaking-change.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + detect-breaking-changes: + name: Diff OpenAPI Spec + runs-on: ubuntu-latest + + env: + # Minimal env so NestJS can bootstrap without a real database/redis + NODE_ENV: production + JWT_SECRET: ci-placeholder-secret-32-chars-ok + JWT_REFRESH_SECRET: ci-placeholder-refresh-secret-ok + JWT_ACCESS_TOKEN_EXPIRATION: 15m + JWT_REFRESH_TOKEN_EXPIRATION: 7d + DATABASE_URL: postgres://ci:ci@localhost:5432/ci + REDIS_URL: redis://localhost:6379 + STELLAR_NETWORK: testnet + # Silence optional integrations + SENTRY_DSN: "" + OTEL_SDK_DISABLED: "true" + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full history to check out base branch + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Cache npm dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-backend-npm-${{ hashFiles('BackEnd/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-backend-npm- + + # ── Generate spec for the PR branch ──────────────────────────────────── + - name: Install dependencies (PR branch) + run: npm ci + working-directory: BackEnd + + - name: Generate OpenAPI spec (PR branch) + run: npx ts-node -r tsconfig-paths/register scripts/generate-openapi.ts openapi-pr.json + working-directory: BackEnd + + # ── Switch to base branch and generate its spec ──────────────────────── + - name: Checkout base branch (${{ github.base_ref }}) + run: git checkout origin/${{ github.base_ref }} + + - name: Install dependencies (base branch) + run: npm ci + working-directory: BackEnd + + - name: Generate OpenAPI spec (base branch) + run: npx ts-node -r tsconfig-paths/register scripts/generate-openapi.ts openapi-base.json + working-directory: BackEnd + + # ── Restore PR files so artifacts are accessible ─────────────────────── + - name: Restore PR branch files + run: git checkout ${{ github.sha }} -- BackEnd/openapi-pr.json + + # ── Install oasdiff and run the diff ─────────────────────────────────── + - name: Install oasdiff + run: | + curl -sSL https://raw.githubusercontent.com/tufin/oasdiff/main/install.sh | sh + echo "$HOME/.oasdiff/bin" >> $GITHUB_PATH + + - name: Detect breaking changes + id: diff + run: | + set +e + oasdiff breaking \ + BackEnd/openapi-base.json \ + BackEnd/openapi-pr.json \ + --format text > breaking-changes.txt 2>&1 + EXIT_CODE=$? + set -e + + echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT + + if [ -s breaking-changes.txt ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Post PR comment with breaking changes + if: steps.diff.outputs.has_changes == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const changes = fs.readFileSync('breaking-changes.txt', 'utf8'); + const body = [ + '## ⚠️ OpenAPI Breaking Changes Detected', + '', + 'The following breaking changes were found between `${{ github.base_ref }}` and this PR:', + '', + '```', + changes.trim(), + '```', + '', + 'Please review these changes. If they are intentional, bump the API version and update the changelog.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## ⚠️ OpenAPI Breaking Changes Detected') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Upload spec artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: openapi-specs-${{ github.run_id }} + path: | + BackEnd/openapi-base.json + BackEnd/openapi-pr.json + breaking-changes.txt + retention-days: 14 + + - name: Fail on breaking changes + if: steps.diff.outputs.exit_code != '0' + run: | + echo "::error::Breaking API changes detected. See the PR comment and breaking-changes.txt artifact for details." + exit 1 + + - name: Workflow summary + if: always() + run: | + echo "## OpenAPI Breaking Change Detection" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.diff.outputs.exit_code }}" = "0" ]; then + echo "✅ No breaking changes detected." >> $GITHUB_STEP_SUMMARY + else + echo "❌ Breaking changes were found — see artifact \`breaking-changes.txt\`." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat breaking-changes.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi diff --git a/BackEnd/.gitignore b/BackEnd/.gitignore index a1a2e3e7f..4ff47a375 100644 --- a/BackEnd/.gitignore +++ b/BackEnd/.gitignore @@ -59,3 +59,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json /generated/prisma /exports + +# Generated OpenAPI spec (produced by scripts/generate-openapi.ts) +openapi.json +openapi-base.json +openapi-pr.json diff --git a/BackEnd/package.json b/BackEnd/package.json index 752aee38f..04f31f12f 100644 --- a/BackEnd/package.json +++ b/BackEnd/package.json @@ -45,6 +45,7 @@ "schema:sync": "bun run typeorm -- schema:sync", "schema:drop": "bun run typeorm -- schema:drop", "schema:log": "bun run typeorm -- schema:log", + "generate:openapi": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts openapi.json" "check:toolchain": "ts-node scripts/check-toolchain.ts", "onboarding": "ts-node scripts/check-toolchain.ts" }, diff --git a/BackEnd/scripts/generate-openapi.ts b/BackEnd/scripts/generate-openapi.ts index a4bdd7b47..6c1b3c7fd 100644 --- a/BackEnd/scripts/generate-openapi.ts +++ b/BackEnd/scripts/generate-openapi.ts @@ -1,4 +1,43 @@ /** + * Standalone script to generate the OpenAPI spec JSON file. + * Used by CI to produce a spec artifact for breaking-change diffing. + * + * Usage: ts-node scripts/generate-openapi.ts [output-path] + * Default output: openapi.json (repo root of BackEnd) + */ +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; + +// Minimal env defaults so the app can bootstrap without a full .env +process.env.NODE_ENV = process.env.NODE_ENV ?? 'production'; +process.env.JWT_SECRET = + process.env.JWT_SECRET ?? 'ci-placeholder-secret-32-chars-ok'; +process.env.JWT_REFRESH_SECRET = + process.env.JWT_REFRESH_SECRET ?? 'ci-placeholder-refresh-secret-ok'; +process.env.JWT_ACCESS_TOKEN_EXPIRATION = + process.env.JWT_ACCESS_TOKEN_EXPIRATION ?? '15m'; +process.env.JWT_REFRESH_TOKEN_EXPIRATION = + process.env.JWT_REFRESH_TOKEN_EXPIRATION ?? '7d'; +process.env.DATABASE_URL = + process.env.DATABASE_URL ?? 'postgres://ci:ci@localhost:5432/ci'; +process.env.REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; +process.env.STELLAR_NETWORK = process.env.STELLAR_NETWORK ?? 'testnet'; + +async function generate() { + // Lazy import so env defaults are set first + const { AppModule } = await import('../src/app.module'); + + const app = await NestFactory.create(AppModule, { logger: false }); + app.setGlobalPrefix('api'); + + const builder = new DocumentBuilder() + .setTitle('StellarEarn API') + .setDescription('Quest-based earning platform on Stellar blockchain') + .setVersion('1.0') + .addServer('/api/v1', 'API v1') + .addServer('/api/v2', 'API v2') * generate-openapi.ts * * Headless NestJS bootstrap that produces the OpenAPI JSON specification @@ -113,6 +152,17 @@ async function main(): Promise { deepScanRoutes: true, }); + const outputPath = resolve( + process.argv[2] ?? `${__dirname}/../openapi.json`, + ); + writeFileSync(outputPath, JSON.stringify(document, null, 2)); + console.log(`OpenAPI spec written to ${outputPath}`); + + await app.close(); +} + +generate().catch((err) => { + console.error('Failed to generate OpenAPI spec:', err); // Validate the generated document has the expected structure if (!document.openapi) { throw new Error('[openapi-gen] Generated document is missing "openapi" field'); diff --git a/BackEnd/tsconfig.json b/BackEnd/tsconfig.json index d8b789903..2c87b1d62 100644 --- a/BackEnd/tsconfig.json +++ b/BackEnd/tsconfig.json @@ -26,6 +26,6 @@ "#src/*": ["src/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "exclude": ["node_modules", "dist"] } \ No newline at end of file diff --git a/BackEnd/tsconfig.scripts.json b/BackEnd/tsconfig.scripts.json new file mode 100644 index 000000000..5770914eb --- /dev/null +++ b/BackEnd/tsconfig.scripts.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-scripts", + "noEmit": true + }, + "include": ["scripts/**/*", "src/**/*"] +}