.github/.github/workflows/ci-frontend.yaml
Adam Moussa 216604ad67
feat(ci): add HCP reusable workflows and drop Mergify (#145)
* feat(ci): add HCP reusable workflows and drop Mergify

Callers can pin org Fargate/SPA CD and parallel CI instead of copying per-repo deploy jobs.

* chore(ci): remove deprecated PR policy reusable

policy / pr is no longer a required check. Drop the callable, its unit tests, and the setup docs so callers stop pinning a retired gate.
2026-09-22 19:22:23 +00:00

262 lines
9.4 KiB
YAML

name: CI — Frontend
# Parallel CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
# with vitest + Playwright). Jobs: guard, static, build, unit (optional shards),
# browser-smoke. The caller owns the `ci-complete` aggregator and ruleset check.
# Do not put these portion names in an org ruleset.
#
# Remaining-lane repos that still need the sequential `ci / ci` context should
# keep calling ci-typescript-frontend.yaml until they migrate.
#
# Caller example:
# jobs:
# frontend:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@<sha> # vX.Y.Z
# with:
# node-version: "24"
# unit-shards: 4
# run-e2e: true
on:
workflow_call:
inputs:
node-version:
description: "Node.js version to use"
type: string
default: "24"
unit-shards:
description: "Vitest shard count (1-8). PR UI shows unit (1) .. unit (N)."
type: number
default: 1
run-e2e:
description: "Run the test:e2e script (Playwright browser smoke)"
type: boolean
default: true
required-scripts:
description: "Comma-separated npm scripts that must exist in package.json"
type: string
default: "format:check,lint,build,test,test:e2e"
working-directory:
description: "Directory to run npm/build/test commands from"
type: string
default: "."
permissions:
contents: read
jobs:
guard:
name: guard
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-guard
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Verify unit-shards
env:
UNIT_SHARDS: ${{ inputs.unit-shards }}
run: |
set -euo pipefail
if [ "${UNIT_SHARDS}" -lt 1 ] || [ "${UNIT_SHARDS}" -gt 8 ]; then
echo "unit-shards must be between 1 and 8 (got ${UNIT_SHARDS})" >&2
exit 1
fi
- name: Verify required npm scripts
env:
REQUIRED_SCRIPTS: ${{ inputs.required-scripts }}
RUN_E2E: ${{ inputs.run-e2e }}
run: |
node <<'NODE'
const { readFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const required = (process.env.REQUIRED_SCRIPTS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.filter((script) => process.env.RUN_E2E !== "false" || script !== "test:e2e");
const missing = required.filter((script) => !pkg.scripts?.[script]);
if (missing.length > 0) {
console.error(`Missing required scripts: ${missing.join(", ")}`);
process.exit(1);
}
console.log(`All required scripts present: ${required.join(", ")}`);
NODE
- name: Guard changed lines
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = "pull_request" ]; then
BASE_REF="${PR_BASE_SHA}"
elif [ "${EVENT_NAME}" = "merge_group" ]; then
BASE_REF="${MERGE_GROUP_BASE_SHA}"
else
BASE_REF="${PUSH_BEFORE}"
fi
if [ -z "${BASE_REF}" ] || [ "${BASE_REF}" = "0000000000000000000000000000000000000000" ]; then
BASE_REF="$(git rev-parse HEAD~1 2>/dev/null || true)"
fi
if [ -z "${BASE_REF}" ]; then
echo "No base ref available; skipping changed-line guard."
exit 0
fi
ADDED_LINES="$(git diff --unified=0 "${BASE_REF}" "${GITHUB_SHA}" | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
if printf '%s\n' "${ADDED_LINES}" | grep -E 'Generated with (Claude [C]ode|[C]odex|Chat[G]PT)|--no-[v]erify|HUSKY[=]0'; then
echo "Found generated-tool footer or hook bypass wording in added lines."
exit 1
fi
if printf '%s\n' "${ADDED_LINES}" | grep -E 'A[K]IA[0-9A-Z]{16}|A[S]IA[0-9A-Z]{16}|AWS[_]SECRET[_]ACCESS[_]KEY|aws[_-]?secret[_-]?access[_-]?key|J[W]TAuthenticationHIGHsecuredPassword|P[a]ssword=|S[e]ndGrid.*A[p]iKey'; then
echo "Found a likely secret in added lines. Move sensitive values to the environment or secret manager."
exit 1
fi
echo "Changed-line guard passed."
- name: Conventions check
working-directory: ${{ github.workspace }}
run: |
errors=0
fail() { echo "::error::$1"; errors=$((errors + 1)); }
[[ -f README.md ]] || fail "Missing README.md"
if [[ -f .gitignore ]]; then
grep -qE '^\.env$|^\.env\b' .gitignore || fail ".gitignore does not include .env"
else
fail "Missing .gitignore"
fi
if [[ $errors -gt 0 ]]; then
echo "Conventions check failed with $errors error(s)."
exit 1
fi
echo "Conventions check passed."
static:
name: static
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-static
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- run: npm run format:check
- run: npm run lint
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-build
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- run: npm run build
unit:
name: unit (${{ matrix.shard }})
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-unit-${{ matrix.shard }}
cancel-in-progress: true
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(format('[{0}]', inputs.unit-shards == 1 && '1' || inputs.unit-shards == 2 && '1,2' || inputs.unit-shards == 3 && '1,2,3' || inputs.unit-shards == 4 && '1,2,3,4' || inputs.unit-shards == 5 && '1,2,3,4,5' || inputs.unit-shards == 6 && '1,2,3,4,5,6' || inputs.unit-shards == 7 && '1,2,3,4,5,6,7' || '1,2,3,4,5,6,7,8')) }}
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- name: Unit tests
env:
SHARD: ${{ matrix.shard }}
SHARDS: ${{ inputs.unit-shards }}
run: npm test -- --shard="${SHARD}/${SHARDS}"
browser-smoke:
name: browser-smoke
if: ${{ inputs.run-e2e }}
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-browser-smoke
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
- run: npm ci
- name: Browser smoke
env:
CI: "true"
run: |
npx playwright install --with-deps chromium
npm run test:e2e