refactor(api): extend #206 type-safety to security (#359 batch 8) #872
Workflow file for this run
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: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: ${{ github.repository_owner }}/artifact-keeper-web | |
| jobs: | |
| lint: | |
| name: Lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run lint | |
| test: | |
| name: Unit Tests | |
| needs: lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run test:coverage | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: coverage-report | |
| path: coverage/ | |
| retention-days: 14 | |
| - name: Coverage summary and gate | |
| run: | | |
| # Parse coverage summary from vitest output | |
| if [ -f coverage/coverage-summary.json ]; then | |
| RESULT=$(python3 -c " | |
| import json | |
| data = json.load(open('coverage/coverage-summary.json')) | |
| total = data.get('total', {}) | |
| lines = total.get('lines', {}) | |
| pct = lines.get('pct', 0) | |
| covered = lines.get('covered', 0) | |
| total_lines = lines.get('total', 0) | |
| print(f'{pct} {covered} {total_lines}') | |
| ") | |
| PCT=$(echo "$RESULT" | awk '{print $1}') | |
| echo "### Coverage: ${PCT}% lines" >> $GITHUB_STEP_SUMMARY | |
| echo "Total coverage: ${PCT}%" | |
| else | |
| echo "No coverage summary found" | |
| PCT="0" | |
| fi | |
| - name: New code coverage gate | |
| if: github.event_name == 'pull_request' | |
| run: | | |
| git fetch --no-tags --depth=1 origin ${{ github.base_ref }} | |
| CHANGED_FILES=$(git diff --name-only FETCH_HEAD -- 'src/**/*.ts' 'src/**/*.tsx' 2>/dev/null | grep -v '\.test\.\|\.spec\.\|__tests__' || true) | |
| if [ -z "$CHANGED_FILES" ]; then | |
| echo "No source files changed, skipping new code coverage check" | |
| echo "### New Code Coverage: N/A (no source changes)" >> $GITHUB_STEP_SUMMARY | |
| exit 0 | |
| fi | |
| if [ ! -f coverage/lcov.info ]; then | |
| echo "No lcov.info found, skipping" | |
| exit 0 | |
| fi | |
| RESULT=$(python3 -c " | |
| import sys | |
| changed = set('''$CHANGED_FILES'''.split()) | |
| hit = miss = 0 | |
| in_changed = False | |
| for line in open('coverage/lcov.info'): | |
| line = line.strip() | |
| if line.startswith('SF:'): | |
| path = line[3:] | |
| in_changed = any(path.endswith(f) for f in changed) | |
| elif line.startswith('DA:') and in_changed: | |
| parts = line[3:].split(',') | |
| count = int(parts[1]) | |
| if count > 0: | |
| hit += 1 | |
| else: | |
| miss += 1 | |
| total = hit + miss | |
| if total == 0: | |
| print('none') | |
| else: | |
| pct = (hit * 100) // total | |
| print(f'{pct} {hit} {total}') | |
| ") | |
| if [ "$RESULT" = "none" ]; then | |
| echo "### New Code Coverage: N/A (no instrumented lines)" >> $GITHUB_STEP_SUMMARY | |
| exit 0 | |
| fi | |
| PCT=$(echo "$RESULT" | awk '{print $1}') | |
| HIT=$(echo "$RESULT" | awk '{print $2}') | |
| TOTAL=$(echo "$RESULT" | awk '{print $3}') | |
| echo "### New Code Coverage: ${PCT}% (${HIT}/${TOTAL} lines)" >> $GITHUB_STEP_SUMMARY | |
| echo "New code coverage: ${PCT}% (${HIT}/${TOTAL} lines)" | |
| if [ "$PCT" -lt 80 ]; then | |
| echo "::error::New code coverage is ${PCT}%, below 80% threshold" | |
| exit 1 | |
| fi | |
| - name: Code duplication gate | |
| if: always() && github.event_name == 'pull_request' | |
| env: | |
| JSCPD_REPORT: /tmp/jscpd-report/jscpd-report.json | |
| run: | | |
| set -euo pipefail | |
| npm install -g jscpd@4 --silent | |
| git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" | |
| CHANGED_SRC=$(git diff --name-only FETCH_HEAD -- 'src/**/*.ts' 'src/**/*.tsx' \ | |
| | grep -v '\.test\.\|\.spec\.\|__tests__' || true) | |
| if [ -z "$CHANGED_SRC" ]; then | |
| echo "No source files changed against ${{ github.base_ref }}" | |
| echo "### Code Duplication: N/A (no source changes)" >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| echo "Analyzing $(echo "$CHANGED_SRC" | wc -l | tr -d ' ') changed source file(s):" | |
| echo "$CHANGED_SRC" | sed 's/^/ /' | |
| echo | |
| jscpd --min-lines 10 --threshold 100 --reporters json \ | |
| --format typescript,tsx --output /tmp/jscpd-report \ | |
| $(echo "$CHANGED_SRC" | tr '\n' ' ') | |
| if [ ! -f "$JSCPD_REPORT" ]; then | |
| echo "jscpd produced no report at $JSCPD_REPORT" | |
| echo "### Code Duplication: 0% (no report)" >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| read -r PCT DUPS LINES CLONES < <(python3 <<'PY' | |
| import json | |
| with open("/tmp/jscpd-report/jscpd-report.json") as f: | |
| data = json.load(f) | |
| total = data.get("statistics", {}).get("total", {}) | |
| print( | |
| f"{total.get('percentage', 0):.1f}", | |
| total.get("duplicatedLines", 0), | |
| total.get("lines", 0), | |
| len(data.get("duplicates", [])), | |
| ) | |
| PY | |
| ) | |
| echo "Code duplication: ${PCT}% (${DUPS}/${LINES} duplicated lines across ${CLONES} clone pair(s))" | |
| echo "### Code Duplication: ${PCT}% (${DUPS}/${LINES} lines, ${CLONES} clone pair(s))" >> "$GITHUB_STEP_SUMMARY" | |
| PCT_INT=$(python3 -c "print(int(round(float('${PCT}') * 10)))") | |
| if [ "$PCT_INT" -gt 30 ]; then | |
| echo "::error::Code duplication is ${PCT}%, exceeding 3% threshold" | |
| python3 <<'PY' | |
| import json | |
| with open("/tmp/jscpd-report/jscpd-report.json") as f: | |
| data = json.load(f) | |
| for d in data.get("duplicates", []): | |
| fa, sa = d["firstFile"], d["secondFile"] | |
| print( | |
| f" {fa['name']}:{fa['startLoc']['line']}-{fa['endLoc']['line']}" | |
| f" <-> {sa['name']}:{sa['startLoc']['line']}-{sa['endLoc']['line']}" | |
| ) | |
| PY | |
| exit 1 | |
| fi | |
| build: | |
| name: Build | |
| needs: [lint, test] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run build | |
| e2e-interactions: | |
| name: E2E Interactions (shard ${{ matrix.shard }}/3) | |
| needs: build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - name: Install Playwright Chromium | |
| run: npx playwright install --with-deps chromium | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Start E2E stack | |
| run: docker compose -f docker-compose.e2e.yml up -d --build --wait | |
| env: | |
| BACKEND_TAG: dev | |
| - name: Run interaction tests | |
| run: npx playwright test --project=interactions --shard=${{ matrix.shard }}/3 --reporter=list,github,html | |
| env: | |
| PLAYWRIGHT_BASE_URL: http://localhost:3100 | |
| ADMIN_PASSWORD: TestRunner!2026secure | |
| - name: Upload test report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: playwright-interactions-shard-${{ matrix.shard }} | |
| path: playwright-report/ | |
| retention-days: 7 | |
| - name: Dump container logs on failure | |
| if: failure() | |
| run: docker compose -f docker-compose.e2e.yml logs --tail=100 | |
| - name: Tear down E2E stack | |
| if: always() | |
| run: docker compose -f docker-compose.e2e.yml down -v | |
| e2e-roles: | |
| name: E2E RBAC Roles | |
| needs: build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - name: Install Playwright Chromium | |
| run: npx playwright install --with-deps chromium | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Start E2E stack | |
| run: docker compose -f docker-compose.e2e.yml up -d --build --wait | |
| env: | |
| BACKEND_TAG: dev | |
| - name: Run RBAC role tests | |
| run: npx playwright test --project=roles-admin --project=roles-developer --project=roles-viewer --project=roles-security --project=roles-restricted --project=roles-unauthenticated --reporter=list,github,html | |
| env: | |
| PLAYWRIGHT_BASE_URL: http://localhost:3100 | |
| ADMIN_PASSWORD: TestRunner!2026secure | |
| - name: Upload test report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: playwright-roles | |
| path: playwright-report/ | |
| retention-days: 7 | |
| - name: Dump container logs on failure | |
| if: failure() | |
| run: docker compose -f docker-compose.e2e.yml logs --tail=100 | |
| - name: Tear down E2E stack | |
| if: always() | |
| run: docker compose -f docker-compose.e2e.yml down -v | |
| e2e-visual: | |
| name: E2E Visual Regression | |
| needs: build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - name: Install Playwright Chromium | |
| run: npx playwright install --with-deps chromium | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Start E2E stack | |
| run: docker compose -f docker-compose.e2e.yml up -d --build --wait | |
| env: | |
| BACKEND_TAG: dev | |
| - name: Run visual regression tests | |
| run: npx playwright test --project=visual --update-snapshots --reporter=list,github,html | |
| env: | |
| PLAYWRIGHT_BASE_URL: http://localhost:3100 | |
| ADMIN_PASSWORD: TestRunner!2026secure | |
| - name: Upload visual baseline snapshots | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: visual-baseline-snapshots | |
| path: e2e/suites/visual/**/*-snapshots/ | |
| retention-days: 30 | |
| - name: Upload visual diff images on failure | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: failure() | |
| with: | |
| name: visual-regression-diffs | |
| path: test-results/ | |
| retention-days: 7 | |
| - name: Upload test report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: playwright-visual | |
| path: playwright-report/ | |
| retention-days: 7 | |
| - name: Dump container logs on failure | |
| if: failure() | |
| run: docker compose -f docker-compose.e2e.yml logs --tail=100 | |
| - name: Tear down E2E stack | |
| if: always() | |
| run: docker compose -f docker-compose.e2e.yml down -v | |
| e2e-docker-proxy: | |
| name: E2E Docker /v2/* Header Forwarding | |
| needs: build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| # This suite uses Playwright's `request` API only — no browser is | |
| # launched, so we skip `playwright install --with-deps` (saves ~90s | |
| # plus chromium dependency install bandwidth). | |
| # | |
| # No docker compose stack either — the suite uses an in-process Node | |
| # fixture registry to verify the Next.js middleware proxy preserves | |
| # Docker request/response headers end to end. See issue #336. | |
| - name: Run Docker /v2/* header-forwarding tests | |
| run: npm run test:e2e:docker-proxy | |
| - name: Upload test report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: playwright-docker-proxy | |
| path: playwright-report/ | |
| retention-days: 7 | |
| e2e-docs-export: | |
| name: E2E Docs Screenshot Export | |
| if: github.ref == 'refs/heads/main' | |
| needs: e2e-visual | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - name: Generate docs manifest | |
| run: npm run test:e2e:docs-export | |
| - name: Upload docs screenshots | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: docs-screenshots | |
| path: e2e/docs-export/ | |
| retention-days: 30 | |
| docker: | |
| name: Docker (${{ matrix.platform }}) | |
| needs: build | |
| if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' | |
| runs-on: ${{ matrix.runner }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: linux/amd64 | |
| runner: ubuntu-24.04 | |
| - platform: linux/arm64 | |
| runner: ubuntu-24.04-arm | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 | |
| id: meta | |
| with: | |
| images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 | |
| with: | |
| context: . | |
| platforms: ${{ matrix.platform }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true | |
| cache-from: type=gha,scope=build-${{ matrix.platform }} | |
| cache-to: type=gha,scope=build-${{ matrix.platform }},mode=max | |
| sbom: true | |
| provenance: mode=max | |
| - name: Export digest | |
| run: | | |
| mkdir -p /tmp/digests | |
| digest="${{ steps.build.outputs.digest }}" | |
| touch "/tmp/digests/${digest#sha256:}" | |
| - name: Upload digest | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} | |
| path: /tmp/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| merge: | |
| name: Create Multi-Arch Manifest | |
| runs-on: ubuntu-latest | |
| needs: docker | |
| steps: | |
| - name: Download digests | |
| uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 | |
| with: | |
| path: /tmp/digests | |
| pattern: digests-* | |
| merge-multiple: true | |
| - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 | |
| id: meta | |
| with: | |
| images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | |
| tags: | | |
| type=raw,value=dev,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} | |
| type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=sha,prefix= | |
| - name: Create manifest list and push | |
| working-directory: /tmp/digests | |
| run: | | |
| docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ | |
| $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) | |
| - name: Inspect image | |
| run: | | |
| docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest |