From ca4b86ab6bcb5643150ea19e279dc6ff438692ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 07:20:57 +0000 Subject: [PATCH] Optimize CI/CD workflows and expand test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #218 — rename workflow files to readable names (ci.yml, cleanup-staging.yml) Closes #219 — harden ci.yml: concurrency cancel, timeout-minutes, correct Vite cache key, output_location comment, remove lfs:false Closes #220 — move npm audit to weekly audit.yml; simplify lint step in ci.yml Closes #221 — fix stale claudeUtils test description ("two model IDs" → one) Closes #222 — extract normalizeName to sportyUtils.js and add 5 unit tests Closes #223 — tag production deploys with GitHub environment (production/preview) Closes #224 — split ci.yml into validate (lint+test) and deploy (build+SWA) jobs Closes #225 — document dev branch deploy behavior with inline comment https://claude.ai/code/session_0111FuxhPFLJ8f6AtZ1Wk4fe --- .github/workflows/audit.yml | 29 +++++++++ ...apps-white-island-090dfd003.yml => ci.yml} | 60 +++++++++++++------ ...-apps-close-pr.yml => cleanup-staging.yml} | 5 +- app/api/__tests__/claudeUtils.test.js | 2 +- app/api/__tests__/sportySync.test.js | 25 ++++++++ app/api/sportySync.js | 6 +- app/api/sportyUtils.js | 4 ++ 7 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/audit.yml rename .github/workflows/{azure-static-web-apps-white-island-090dfd003.yml => ci.yml} (53%) rename .github/workflows/{azure-static-web-apps-close-pr.yml => cleanup-staging.yml} (90%) create mode 100644 app/api/__tests__/sportySync.test.js create mode 100644 app/api/sportyUtils.js diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..4c7bc89 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,29 @@ +name: Weekly dependency audit + +on: + schedule: + - cron: '0 8 * * 1' # Every Monday at 08:00 UTC + workflow_dispatch: # Allow manual trigger + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + name: npm audit + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: app/package-lock.json + - name: Install dependencies + working-directory: app + run: npm ci + - name: Audit production dependencies + working-directory: app + # --omit=dev: dev deps are not in the production bundle + # --audit-level=high: only fail on high/critical vulnerabilities + run: npm audit --audit-level=high --omit=dev diff --git a/.github/workflows/azure-static-web-apps-white-island-090dfd003.yml b/.github/workflows/ci.yml similarity index 53% rename from .github/workflows/azure-static-web-apps-white-island-090dfd003.yml rename to .github/workflows/ci.yml index 7eb404c..3acf06e 100644 --- a/.github/workflows/azure-static-web-apps-white-island-090dfd003.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ -name: Azure Static Web Apps CI/CD +name: CI on: push: + # Direct pushes to dev deploy to the same production slot as master. + # This is intentional — dev acts as a pre-merge integration check. + # If a dedicated staging slot is added later, split the triggers. branches: - master - dev @@ -17,19 +20,23 @@ on: - 'app/**' - '.github/workflows/**' +# Cancel in-progress runs on the same branch when a new push arrives. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build_and_deploy_job: + validate: if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') runs-on: ubuntu-latest - name: Test, Build and Deploy + timeout-minutes: 15 + name: Lint and test permissions: contents: read - pull-requests: write steps: - uses: actions/checkout@v5 with: submodules: true - lfs: false - uses: actions/setup-node@v5 with: node-version: '22' @@ -38,26 +45,41 @@ jobs: - name: Install dependencies working-directory: app run: npm ci - - name: Lint and audit + - name: Lint working-directory: app - run: | - npm run lint & - LINT_PID=$! - npm audit --audit-level=high & - AUDIT_PID=$! - wait $LINT_PID - LINT_EXIT=$? - wait $AUDIT_PID - AUDIT_EXIT=$? - if [ $LINT_EXIT -ne 0 ] || [ $AUDIT_EXIT -ne 0 ]; then exit 1; fi + run: npm run lint - name: Test with coverage working-directory: app run: npm run test:ci + + deploy: + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 15 + name: Build and deploy + environment: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }} + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: app/package-lock.json + - name: Install dependencies + working-directory: app + run: npm ci - name: Restore Vite build cache uses: actions/cache@v5 with: path: app/node_modules/.vite - key: vite-${{ runner.os }}-${{ hashFiles('app/src/**', 'app/vite.config.js') }} + # Keyed on deps + vite config only — source changes don't affect + # the pre-bundled dependency cache in node_modules/.vite. + key: vite-${{ runner.os }}-${{ hashFiles('app/package-lock.json', 'app/vite.config.js') }} restore-keys: vite-${{ runner.os }}- - name: Build app working-directory: app @@ -65,7 +87,7 @@ jobs: env: VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} - - name: Build And Deploy + - name: Deploy to Azure Static Web Apps id: builddeploy uses: Azure/static-web-apps-deploy@v1 with: @@ -74,4 +96,6 @@ jobs: action: "upload" app_location: "app/dist" api_location: "app/api" + # "." = root of the pre-built app/dist folder. The frontend is built + # in CI to bypass Oryx stripping VITE_* env vars. See CLAUDE.md pitfall #9. output_location: "." diff --git a/.github/workflows/azure-static-web-apps-close-pr.yml b/.github/workflows/cleanup-staging.yml similarity index 90% rename from .github/workflows/azure-static-web-apps-close-pr.yml rename to .github/workflows/cleanup-staging.yml index ef3f08f..b336023 100644 --- a/.github/workflows/azure-static-web-apps-close-pr.yml +++ b/.github/workflows/cleanup-staging.yml @@ -1,4 +1,4 @@ -name: Azure Static Web Apps — Close PR +name: Cleanup staging environment run-name: "Cleanup staging env — ${{ github.event.pull_request.title }}" on: @@ -11,7 +11,8 @@ on: jobs: close_pull_request_job: runs-on: ubuntu-latest - name: Close Pull Request Job + timeout-minutes: 5 + name: Close staging environment permissions: contents: read pull-requests: write diff --git a/app/api/__tests__/claudeUtils.test.js b/app/api/__tests__/claudeUtils.test.js index 0d185ee..d29f321 100644 --- a/app/api/__tests__/claudeUtils.test.js +++ b/app/api/__tests__/claudeUtils.test.js @@ -8,7 +8,7 @@ import { } from '../claudeUtils.js'; describe('ALLOWED_MODELS', () => { - it('contains exactly the two production model IDs', () => { + it('contains the production model ID', () => { expect([...ALLOWED_MODELS].sort()).toEqual(['claude-sonnet-4-6']); }); diff --git a/app/api/__tests__/sportySync.test.js b/app/api/__tests__/sportySync.test.js new file mode 100644 index 0000000..f844e06 --- /dev/null +++ b/app/api/__tests__/sportySync.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeName } from '../sportyUtils.js'; + +describe('normalizeName', () => { + it('strips a straight-quote annotation', () => { + expect(normalizeName('CROSSFIT "SVART TRØYE"')).toBe('CROSSFIT'); + }); + + it('strips a curly-quote annotation', () => { + // U+201C left double quotation mark, U+201D right double quotation mark + expect(normalizeName('YOGA “KVELDSRO”')).toBe('YOGA'); + }); + + it('leaves a plain name unchanged', () => { + expect(normalizeName('PILATES')).toBe('PILATES'); + }); + + it('returns empty string when the entire name is an annotation', () => { + expect(normalizeName('"ANNONSERT"')).toBe(''); + }); + + it('trims whitespace left after stripping', () => { + expect(normalizeName('CROSSFIT "SVART TRØYE" ')).toBe('CROSSFIT'); + }); +}); diff --git a/app/api/sportySync.js b/app/api/sportySync.js index b31ba42..03b647f 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -1,4 +1,5 @@ import { app } from '@azure/functions'; +import { normalizeName } from './sportyUtils.js'; // BU 8 is hardcoded for the single-gym MVP (Sporty Thon Senter Ski). // When multi-gym support lands, replace with a DB lookup via user_gyms.sporty_business_unit_id. @@ -17,11 +18,6 @@ function buildSportyUrl(daysAhead = 10) { return `${SPORTY_BASE_URL}?period_start=${encodeURIComponent(start.toISOString())}&period_end=${encodeURIComponent(end.toISOString())}`; } -// Strip quoted annotations like "SVART TRØYE" — handles straight and curly double quotes -function normalizeName(name) { - return name.replace(/\s*["“”][^"“”]+["“”]\s*/g, '').trim(); -} - function shiftRow(row, shiftMs) { if (!shiftMs) return row; return { diff --git a/app/api/sportyUtils.js b/app/api/sportyUtils.js new file mode 100644 index 0000000..5f67888 --- /dev/null +++ b/app/api/sportyUtils.js @@ -0,0 +1,4 @@ +// Strip quoted annotations like “SVART TRØYE” — handles straight and curly double quotes +export function normalizeName(name) { + return name.replace(/\s*["“”][^"“”]+["“”]\s*/g, '').trim(); +}