Skip to content

build(deps): bump the dependencies group across 1 directory with 6 updates #464

build(deps): bump the dependencies group across 1 directory with 6 updates

build(deps): bump the dependencies group across 1 directory with 6 updates #464

Workflow file for this run

name: Frontend CI/CD
# Simplified Testing Strategy: TypeScript-first quality gates for solo maintainer
# - CI runs only: TypeScript compilation + ESLint + Next.js build
# - E2E tests (Playwright) available locally only (not in CI)
# - Path-based triggering provides cost optimization
# - Manual force deployment option available via workflow_dispatch
on:
push:
branches: [main]
paths:
- 'apps/web/**'
- 'pnpm-lock.yaml'
- '.github/workflows/frontend-ci.yml'
- '.github/workflows/reusable-security-scan.yml'
# Exclude documentation and non-functional changes to save costs
- '!apps/web/**/*.md'
- '!apps/web/**/*.txt'
- '!apps/web/**/*.gitignore'
- '!apps/web/**/README*'
- '!apps/web/**/.editorconfig'
- '!apps/web/**/*.test.*' # Test files (tests run locally only, not in CI)
# No `paths:` filter on pull_request — deliberately. See backend-ci.yml for the
# full rationale: "Lint & Type Check" is a REQUIRED status check, and a path
# filter here means it never reports on a backend-only PR, so branch protection
# blocks that PR forever. The workflow now always triggers and the expensive
# jobs skip when apps/web is untouched (see the `changes` job) — a skipped job
# still reports its check. The `push` filter above is untouched, so deploys
# stay path-gated.
pull_request:
branches: [main]
workflow_call:
inputs:
skip-deploy:
description: 'Skip deployment steps'
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
deploy:
description: 'Deploy to production'
required: true
default: false
type: boolean
force_deploy:
description: 'Force deployment even without code changes'
required: false
default: false
type: boolean
deploy_ref:
description: 'Git SHA or tag to deploy (for rollbacks). Leave empty for current main.'
required: false
default: ''
type: string
env:
REGISTRY: us-east1-docker.pkg.dev
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
REGION: us-east1
SERVICE_NAME: nosilha-frontend
IMAGE_TAG: ${{ inputs.deploy_ref || github.sha }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Cheap path detection so the heavy jobs below can skip on PRs that do not
# touch the frontend, while still reporting their (required) check as skipped.
changes:
name: Detect Frontend Changes
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
frontend:
- 'apps/web/**'
- '!apps/web/**/*.md'
- '!apps/web/**/*.txt'
- '!apps/web/**/README*'
- '!apps/web/**/.gitignore'
- '!apps/web/**/.editorconfig'
- 'pnpm-lock.yaml'
- 'package.json'
- '.github/workflows/frontend-ci.yml'
- '.github/workflows/reusable-security-scan.yml'
security-scan:
name: Security Scanning
needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.frontend == 'true'
uses: ./.github/workflows/reusable-security-scan.yml
with:
components: 'frontend'
include-lint-analysis: true
test-and-lint:
name: Lint & Type Check
runs-on: ubuntu-latest
needs: [changes]
# Skips (and so reports "skipped", satisfying branch protection) when the PR
# does not touch the frontend. Always runs on push/dispatch.
if: github.event_name != 'pull_request' || needs.changes.outputs.frontend == 'true'
defaults:
run:
working-directory: ./apps/web
steps:
- uses: actions/checkout@v7
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
# Generate Velite content types before type checking
- name: Generate content types
run: pnpm build:content
# Run linting and type checking
# Note: ESLint with SARIF output is handled by reusable-security-scan.yml to avoid duplication
- name: Run linting and type checks
run: |
# Run linting and type checking in parallel.
# Bare `wait` always exits 0, so wait on each PID to propagate failures.
# `|| rc=$?` keeps `set -e` from aborting before both results are captured.
lint_rc=0
tsc_rc=0
pnpm lint & lint_pid=$!
pnpm exec tsc --noEmit & tsc_pid=$!
wait $lint_pid || lint_rc=$?
wait $tsc_pid || tsc_rc=$?
echo "eslint exit=$lint_rc, tsc exit=$tsc_rc"
[ $lint_rc -eq 0 ] && [ $tsc_rc -eq 0 ]
env:
NODE_ENV: production
- name: Build application
run: pnpm build
env:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_GA_ID: ${{ secrets.NEXT_PUBLIC_GA_ID }}
NEXT_PUBLIC_CLARITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_CLARITY_PROJECT_ID }}
bundle-analysis:
name: Bundle Size Analysis
runs-on: ubuntu-latest
needs: [changes]
# Also gated on `changes` — this job runs a full build, and without the
# workflow-level path filter it would otherwise fire on backend-only PRs.
if: |
github.event_name == 'pull_request' &&
!github.event.pull_request.draft &&
needs.changes.outputs.frontend == 'true'
defaults:
run:
working-directory: ./apps/web
steps:
- uses: actions/checkout@v7
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@v4
with:
filters: |
bundle:
- 'apps/web/package.json'
- 'pnpm-lock.yaml'
- 'apps/web/src/**'
- 'apps/web/next.config.ts'
- name: Setup pnpm
if: ${{ steps.changes.outputs.bundle == 'true' }}
uses: pnpm/action-setup@v6
- name: Setup Node.js 24
if: ${{ steps.changes.outputs.bundle == 'true' }}
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install dependencies
if: ${{ steps.changes.outputs.bundle == 'true' }}
run: pnpm install --frozen-lockfile
- name: Build and check bundle size
if: ${{ steps.changes.outputs.bundle == 'true' }}
run: |
pnpm build
# Check bundle size and fail if exceeds 500KB threshold
echo "Analyzing bundle size..."
BUNDLE_SIZE_KB=$(du -sk .next/static/chunks | cut -f1)
BUNDLE_SIZE_MB=$(echo "scale=2; $BUNDLE_SIZE_KB / 1024" | bc)
echo "Total bundle size: ${BUNDLE_SIZE_MB}MB (${BUNDLE_SIZE_KB}KB)"
# Fail if bundle exceeds 500KB (512000KB with some buffer)
if [ $BUNDLE_SIZE_KB -gt 512000 ]; then
echo "❌ Bundle size exceeds 500KB limit!"
echo "Current size: ${BUNDLE_SIZE_MB}MB"
echo "Maximum allowed: 500KB"
exit 1
else
echo "✅ Bundle size within limits (max 500KB)"
fi
# Run bundlesize check if configured
if [ -f ".bundlesizerc" ] || grep -q "bundlesize" package.json; then
npx bundlesize
fi
env:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_GA_ID: ${{ secrets.NEXT_PUBLIC_GA_ID }}
NEXT_PUBLIC_CLARITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_CLARITY_PROJECT_ID }}
# Note: Path-based triggering (on.push.paths) provides cost optimization
# while ensuring reliable deployment when the workflow runs
build:
name: Build & Package
runs-on: ubuntu-latest
needs: [security-scan, test-and-lint]
# Always build when workflow runs (reliability over cost optimization)
if: |
(github.ref == 'refs/heads/main' && !inputs.skip-deploy) ||
(github.event_name == 'workflow_dispatch' && (inputs.deploy == true || inputs.force_deploy == true))
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
defaults:
run:
working-directory: ./apps/web
# Add OIDC permissions
permissions:
contents: read
id-token: write # Required for OIDC token requests
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.deploy_ref || github.sha }}
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
with:
install-dependencies: 'false'
- name: GCP Authentication
uses: ./.github/actions/gcp-auth
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/nosilha-frontend/nosilha-web-ui
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/nosilha-frontend/nosilha-web-ui:${{ github.sha }}
${{ github.ref == 'refs/heads/main' && format('{0}/{1}/nosilha-frontend/nosilha-web-ui:latest', env.REGISTRY, env.PROJECT_ID) || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
NEXT_PUBLIC_CLARITY_PROJECT_ID=${{ secrets.NEXT_PUBLIC_CLARITY_PROJECT_ID }}
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [build]
if: |
(github.ref == 'refs/heads/main' && needs.build.result == 'success' && !inputs.skip-deploy) ||
(github.event_name == 'workflow_dispatch' && needs.build.result == 'success' && (inputs.deploy == true || inputs.force_deploy == true))
environment: production
# Add OIDC permissions
permissions:
contents: read
id-token: write # Required for OIDC token requests
steps:
- uses: actions/checkout@v7
- name: GCP Authentication
uses: ./.github/actions/gcp-auth
with:
configure-docker: 'false'
- name: Deploy to Cloud Run (Production)
run: |
gcloud run deploy ${{ env.SERVICE_NAME }} \
--image=${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/nosilha-frontend/nosilha-web-ui:${{ github.sha }} \
--platform=managed \
--region=${{ env.REGION }} \
--service-account=nosilha-frontend-runner@${{ env.PROJECT_ID }}.iam.gserviceaccount.com \
--memory=512Mi \
--cpu=1 \
--min-instances=0 \
--max-instances=2 \
--timeout=300
- name: Health Check
run: |
SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} --region=${{ env.REGION }} --format='value(status.url)')
echo "Service URL: $SERVICE_URL"
# Wait for service to be ready
sleep 30
# Basic health check
curl -f "$SERVICE_URL" || (echo "Health check failed" && exit 1)
- name: Warm ISR Cache
run: |
SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} --region=${{ env.REGION }} --format='value(status.url)')
# Trigger homepage revalidation so Instagram data fetches with runtime token
curl -sf -X POST "$SERVICE_URL/api/revalidate" \
-H "Content-Type: application/json" \
-H "X-Revalidate-Secret: ${{ secrets.REVALIDATE_SECRET }}" \
-d '{"path": "/"}' || echo "Cache warm-up failed (non-fatal)"