Add reusable CI workflow for TypeScript front-end apps

Adds ci-typescript-frontend.yaml, a workflow_call reusable CI for bundled
TypeScript SPAs (Vite / React / Vue with vitest + Playwright). Existing
reusable CIs do not fit this shape: ci-static is for plain HTML sites and
ci-typescript-cdk targets CDK infra repos.

The workflow runs as a single `ci` job so callers emit the `ci / ci` status
context the org branch-protection rulesets require. Steps: a Sea Haven
standards gate (required npm scripts present, plus a changed-line guard for
AI-tool footers, hook bypasses, and hardcoded secrets), then format:check,
lint, build, unit tests, and an optional Playwright browser smoke. Every step
past the standards gate is individually toggleable, and string inputs are
passed through env to avoid expression injection.

Documents the workflow in the README reusable-workflows list.
This commit is contained in:
Adam Moussa 2026-06-24 16:42:54 -04:00
parent 945f0b6021
commit 2e356920c7
2 changed files with 205 additions and 0 deletions

View file

@ -0,0 +1,203 @@
name: CI — TypeScript Frontend
# Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
# with vitest + Playwright). Emits the single `ci / ci` status context required
# by the org branch-protection rulesets — keep the caller job id `ci` so the
# context resolves to `ci / ci`.
#
# Runs, in order: a Sea Haven standards gate (required npm scripts present, no
# AI-tool footers / hook bypasses / hardcoded secrets in the added lines),
# then format:check, lint, build, unit tests, and an optional Playwright
# browser smoke. Every step past the standards gate is individually toggleable.
#
# Caller example:
# jobs:
# ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
# with:
# node-version: "24"
on:
workflow_call:
inputs:
node-version:
description: "Node.js version to use"
type: string
default: "24"
working-directory:
description: "Directory to run npm/build/test commands from"
type: string
default: "."
cache-dependency-path:
description: "Path to package-lock.json for npm cache"
type: string
default: "package-lock.json"
required-scripts:
description: "Comma-separated npm scripts that must exist in package.json"
type: string
default: "format:check,lint,build,test,test:e2e"
run-standards:
description: "Verify required npm scripts exist"
type: boolean
default: true
run-guard:
description: "Guard added lines against AI-tool footers, hook bypasses, and hardcoded secrets"
type: boolean
default: true
run-format-check:
description: "Run the format:check script (Prettier)"
type: boolean
default: true
run-lint:
description: "Run the lint script (ESLint)"
type: boolean
default: true
run-build:
description: "Run the build script"
type: boolean
default: true
run-tests:
description: "Run the test script (vitest / unit tests)"
type: boolean
default: true
run-e2e:
description: "Run the test:e2e script (Playwright browser smoke)"
type: boolean
default: true
e2e-browser:
description: "Playwright browser to install for the e2e smoke"
type: string
default: "chromium"
run-conventions-check:
description: "Require README.md and a .gitignore that covers .env"
type: boolean
default: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: ci-typescript-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Verify required npm scripts
if: ${{ inputs.run-standards }}
env:
REQUIRED_SCRIPTS: ${{ inputs.required-scripts }}
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);
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
if: ${{ inputs.run-guard }}
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = "pull_request" ]; then
BASE_REF="${PR_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: Install dependencies
run: npm ci
- name: Format check
if: ${{ inputs.run-format-check }}
run: npm run format:check
- name: Lint
if: ${{ inputs.run-lint }}
run: npm run lint
- name: Build
if: ${{ inputs.run-build }}
run: npm run build
- name: Unit tests
if: ${{ inputs.run-tests }}
run: npm test
- name: Browser smoke
if: ${{ inputs.run-e2e }}
env:
CI: "true"
E2E_BROWSER: ${{ inputs.e2e-browser }}
run: |
npx playwright install --with-deps "${E2E_BROWSER}"
npm run test:e2e
- name: Conventions check
if: ${{ inputs.run-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."

View file

@ -10,6 +10,8 @@ Organization-level GitHub configuration for Sea Haven Industries.
**`.github/workflows/ci-typescript-cdk.yaml`** — Reusable CI workflow for TypeScript / CDK repos. Runs `npm ci` + optional `tsc --noEmit`, optional ESLint, optional Jest, and optional `cdk synth`. Also supports Node.js SAM repos via an optional `sam validate` step.
**`.github/workflows/ci-typescript-frontend.yaml`** — Reusable CI workflow for bundled TypeScript front-end apps (Vite / React / Vue SPAs). Runs a Sea Haven standards gate (required npm scripts present, no AI-tool footers / hook bypasses / hardcoded secrets in added lines), then `format:check`, `lint`, `build`, vitest unit tests, and an optional Playwright browser smoke. Emits the single `ci / ci` status context — keep the caller job id `ci`.
**`.github/workflows/cd-sam.yaml`** — Reusable CD workflow for SAM repos. Runs `sam build` + `sam deploy` with OIDC credentials and a CloudFormation execution role. Triggers via `workflow_call` from per-repo `deploy.yaml` on push to `main`.
**`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds.