veritas-kanban/.github/workflows/ci.yml

574 lines
20 KiB
YAML

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
inputs:
test_scope:
description: Unit-test verification tier
required: true
default: full
type: choice
options:
- focused
- full
base_sha:
description: Optional base commit for a focused run (defaults to HEAD^)
required: false
type: string
permissions:
contents: read
concurrency:
group: >-
${{
format(
'{0}-{1}-{2}',
github.workflow,
github.ref,
github.event_name == 'pull_request' &&
contains(fromJSON('["labeled","unlabeled"]'), github.event.action) &&
github.event.label.name != 'ci:full' &&
format('cosmetic-{0}', github.run_id) ||
'authoritative'
)
}}
cancel-in-progress: true
env:
NODE_VERSION: '22'
jobs:
# ─── Deterministic Test Scope ───────────────────────────────────
select-tests:
name: Select Test Scope
if: >-
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
runs-on: ubuntu-latest
outputs:
scope: ${{ steps.scope.outputs.scope }}
packages: ${{ steps.scope.outputs.packages }}
base_sha: ${{ steps.scope.outputs.base_sha }}
diff_range: ${{ steps.scope.outputs.diff_range }}
reason: ${{ steps.scope.outputs.reason }}
coverage_packages: ${{ steps.scope.outputs.coverage_packages }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Verify CI scope controls
run: >-
node --test
scripts/check-actions-pinned.test.mjs
scripts/check-delivery-cadence.test.mjs
scripts/check-security-gates.test.mjs
scripts/check-tracked-ignore.test.mjs
scripts/select-ci-test-scope.test.mjs
- name: Guard delivery cadence
run: node scripts/check-delivery-cadence.mjs
- name: Guard immutable GitHub Actions references
run: node scripts/check-actions-pinned.mjs
- name: Guard continuous security gates
run: node scripts/check-security-gates.mjs
- name: Reject tracked files covered by ignore rules
run: node scripts/check-tracked-ignore.mjs
- name: Select verification tier
id: scope
shell: bash
env:
CI_EVENT_NAME: ${{ github.event_name }}
CI_MANUAL_SCOPE: ${{ inputs.test_scope || '' }}
CI_PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }}
PUSH_BEFORE_SHA: ${{ github.event.before || '' }}
DISPATCH_BASE_SHA: ${{ inputs.base_sha || '' }}
run: |
set -euo pipefail
case "$CI_EVENT_NAME" in
pull_request)
CI_BASE_SHA="$PR_BASE_SHA"
CI_HEAD_SHA="$PR_HEAD_SHA"
;;
push)
CI_BASE_SHA="$PUSH_BEFORE_SHA"
CI_HEAD_SHA="$GITHUB_SHA"
if [[ "$CI_BASE_SHA" =~ ^0+$ ]]; then
CI_BASE_SHA="$(git rev-parse "${GITHUB_SHA}^")"
fi
;;
workflow_dispatch)
CI_HEAD_SHA="$GITHUB_SHA"
if [[ -n "$DISPATCH_BASE_SHA" ]]; then
if [[ ! "$DISPATCH_BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
echo "::error::base_sha must be a 7-40 character hexadecimal commit ID"
exit 1
fi
CI_BASE_SHA="$(git rev-parse --verify "${DISPATCH_BASE_SHA}^{commit}")"
else
CI_BASE_SHA="$(git rev-parse "${GITHUB_SHA}^")"
fi
;;
schedule)
CI_BASE_SHA="$GITHUB_SHA"
CI_HEAD_SHA="$GITHUB_SHA"
;;
*)
echo "::error::Unsupported CI event: $CI_EVENT_NAME"
exit 1
;;
esac
export CI_BASE_SHA CI_HEAD_SHA
node scripts/select-ci-test-scope.mjs
# ─── Lint & Type Check ───────────────────────────────────────────
lint-and-typecheck:
name: Lint & Type Check
if: >-
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Check pnpm settings location
run: node scripts/check-pnpm-settings.mjs
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check service filesystem boundary
run: pnpm check:service-filesystem-boundary
- name: Build shared (dependency for typecheck)
run: pnpm --filter @veritas-kanban/shared build
- name: Lint
run: pnpm lint
- name: Enforce lint warning budget
run: pnpm lint:budget
- name: Check permission coverage
run: node scripts/check-permission-coverage.mjs
- name: Type check all packages
run: pnpm typecheck
# ─── Focused Related Tests ──────────────────────────────────────
test-changed:
name: Changed Tests
needs: select-tests
if: >-
always() &&
(
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
)
runs-on: ubuntu-latest
steps:
- name: Require a successful scope decision
env:
SELECTOR_RESULT: ${{ needs.select-tests.result }}
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
SELECTED_PACKAGES: ${{ needs.select-tests.outputs.packages }}
run: |
if [[ "$SELECTOR_RESULT" != "success" ]]; then
echo "::error::Select Test Scope did not complete successfully"
exit 1
fi
if [[ ! "$SELECTED_SCOPE" =~ ^(none|focused|full)$ ]]; then
echo "::error::Select Test Scope returned an invalid scope"
exit 1
fi
if [[ "$SELECTED_SCOPE" == "focused" && -z "$SELECTED_PACKAGES" ]]; then
echo "::error::Focused scope requires at least one workspace"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: needs.select-tests.outputs.scope == 'focused'
with:
fetch-depth: 0
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: needs.select-tests.outputs.scope == 'focused'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
if: needs.select-tests.outputs.scope == 'focused'
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
if: needs.select-tests.outputs.scope == 'focused'
run: pnpm install --frozen-lockfile
- name: Build shared test dependency
if: needs.select-tests.outputs.scope == 'focused'
run: pnpm --filter @veritas-kanban/shared build
- name: Run related tests in affected workspaces
if: needs.select-tests.outputs.scope == 'focused'
env:
DIFF_RANGE: ${{ needs.select-tests.outputs.diff_range }}
SELECTED_PACKAGES: ${{ needs.select-tests.outputs.packages }}
VERITAS_DISABLE_WATCHERS: '1'
shell: bash
run: |
set -euo pipefail
IFS=',' read -r -a packages <<< "$SELECTED_PACKAGES"
executed_packages=()
{
echo "### Changed Tests"
echo
echo "- Diff range: \`$DIFF_RANGE\`"
echo "- Selected workspaces: \`$SELECTED_PACKAGES\`"
} >> "$GITHUB_STEP_SUMMARY"
for package_name in "${packages[@]}"; do
related_files=()
while IFS= read -r changed_file; do
related_files+=("./${changed_file#"$package_name/"}")
done < <(
git diff --name-only --diff-filter=ACMR "$DIFF_RANGE" -- "$package_name/"
)
if (( ${#related_files[@]} == 0 )); then
continue
fi
case "$package_name" in
server|web|cli|mcp)
package_filter="@veritas-kanban/${package_name}"
extra_args=()
if [[ "$package_name" == "web" ]]; then
extra_args+=(--testTimeout 15000)
fi
;;
desktop)
package_filter="@veritas-kanban/desktop"
extra_args=(--config vitest.config.ts)
;;
*)
echo "::error::Unknown selected workspace: $package_name"
exit 1
;;
esac
pnpm --filter "$package_filter" exec vitest related \
--run \
--maxWorkers=4 \
--passWithNoTests \
"${extra_args[@]}" \
"${related_files[@]}"
executed_packages+=("$package_name")
done
{
if (( ${#executed_packages[@]} > 0 )); then
echo "- Related coverage executed for: \`${executed_packages[*]}\`"
else
echo "- No added, copied, modified, or renamed workspace inputs required related coverage."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Record focused-tier skip
if: needs.select-tests.outputs.scope != 'focused'
env:
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
run: |
{
echo "### Changed Tests"
echo
echo "- Decision: skipped related coverage because scope is \`$SELECTED_SCOPE\`."
echo "- Selection reason: $SELECTION_REASON"
} >> "$GITHUB_STEP_SUMMARY"
# ─── Full Workspace Unit Tests ───────────────────────────────────
test-workspace:
name: Workspace Unit Tests
needs: select-tests
if: >-
always() &&
(
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
)
runs-on: ubuntu-latest
steps:
- name: Require a successful scope decision
env:
SELECTOR_RESULT: ${{ needs.select-tests.result }}
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
run: |
if [[ "$SELECTOR_RESULT" != "success" ]]; then
echo "::error::Select Test Scope did not complete successfully"
exit 1
fi
if [[ ! "$SELECTED_SCOPE" =~ ^(none|focused|full)$ ]]; then
echo "::error::Select Test Scope returned an invalid scope"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: needs.select-tests.outputs.scope == 'full'
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: needs.select-tests.outputs.scope == 'full'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
if: needs.select-tests.outputs.scope == 'full'
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
if: needs.select-tests.outputs.scope == 'full'
run: pnpm install --frozen-lockfile
- name: Build shared (dependency for workspace tests)
if: needs.select-tests.outputs.scope == 'full'
run: pnpm --filter @veritas-kanban/shared build
- name: Run workspace unit tests
if: needs.select-tests.outputs.scope == 'full'
run: pnpm test:unit
- name: Run desktop readiness regression tests
if: needs.select-tests.outputs.scope == 'full'
run: pnpm desktop:test:readiness
- name: Run dual-storage parity tests
if: needs.select-tests.outputs.scope == 'full'
run: >-
pnpm --filter @veritas-kanban/server exec vitest run
src/__tests__/storage/dual-storage-parity.test.ts
- name: Record full-suite evidence
if: >-
always() &&
needs.select-tests.result == 'success' &&
needs.select-tests.outputs.scope == 'full'
env:
DIFF_RANGE: ${{ needs.select-tests.outputs.diff_range }}
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
CURRENT_JOB_STATUS: ${{ job.status }}
run: |
{
echo "### Workspace Unit Tests"
echo
echo "- Diff range: \`${DIFF_RANGE:-not required}\`"
echo "- Selection reason: $SELECTION_REASON"
echo "- Workflow checkout SHA: \`$GITHUB_SHA\`"
echo "- Current job status: \`$CURRENT_JOB_STATUS\`"
echo "- Unit-test workspaces: \`server, web, cli, mcp\`"
echo "- Workspace workers: \`4 maximum per Vitest project\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Record full-tier skip
if: needs.select-tests.outputs.scope != 'full'
env:
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
run: |
{
echo "### Workspace Unit Tests"
echo
echo "- Decision: skipped the complete suite because scope is \`$SELECTED_SCOPE\`."
echo "- Selection reason: $SELECTION_REASON"
} >> "$GITHUB_STEP_SUMMARY"
# ─── Critical-path Coverage ─────────────────────────────────────
critical-path-coverage:
name: Critical Path Coverage
needs: select-tests
if: >-
always() &&
(
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
)
runs-on: ubuntu-latest
steps:
- name: Require a successful scope decision
env:
SELECTOR_RESULT: ${{ needs.select-tests.result }}
run: |
if [[ "$SELECTOR_RESULT" != "success" ]]; then
echo "::error::Select Test Scope did not complete successfully"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: needs.select-tests.outputs.coverage_packages != ''
with:
# Policy downgrade and changed-critical-file checks compare against the event base SHA.
fetch-depth: 0
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: needs.select-tests.outputs.coverage_packages != ''
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
if: needs.select-tests.outputs.coverage_packages != ''
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
if: needs.select-tests.outputs.coverage_packages != ''
run: pnpm install --frozen-lockfile
- name: Verify coverage policy
if: needs.select-tests.outputs.coverage_packages != ''
env:
COVERAGE_BASE_REF: ${{ needs.select-tests.outputs.base_sha }}
run: pnpm check:coverage-policy
- name: Measure and ratchet critical paths
if: needs.select-tests.outputs.coverage_packages != ''
env:
COVERAGE_PACKAGES: ${{ needs.select-tests.outputs.coverage_packages }}
COVERAGE_BASE_REF: ${{ needs.select-tests.outputs.base_sha }}
run: pnpm test:coverage --packages "$COVERAGE_PACKAGES"
- name: Upload coverage reports
if: always() && needs.select-tests.outputs.coverage_packages != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: critical-path-coverage-${{ github.sha }}
path: coverage/
if-no-files-found: warn
retention-days: 14
- name: Record coverage skip
if: needs.select-tests.outputs.coverage_packages == ''
run: |
{
echo "### Critical-path coverage ratchets"
echo
echo "No governed critical-path package changed in this verification scope."
} >> "$GITHUB_STEP_SUMMARY"
# ─── Build ───────────────────────────────────────────────────────
build:
name: Build
if: >-
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared (dependency for all builds)
run: pnpm --filter @veritas-kanban/shared build
- name: Verify native Vite config loading
run: pnpm check:vite-native-config
- name: Build all packages
run: pnpm build
- name: Verify web build output
run: |
if [ ! -d "web/dist" ]; then
echo "::error::Web build output (web/dist) not found"
exit 1
fi
echo "✅ Web build output exists"
ls -la web/dist/
- name: Verify server build output
run: |
if [ ! -d "server/dist" ]; then
echo "::error::Server build output (server/dist) not found"
exit 1
fi
echo "✅ Server build output exists"
ls -la server/dist/
- name: Verify CLI and MCP build output
run: |
for file in cli/dist/index.js mcp/dist/index.js; do
if [ ! -f "$file" ]; then
echo "::error::$file not found"
exit 1
fi
done
echo "✅ CLI and MCP build outputs exist"
# ─── Security Audit ──────────────────────────────────────────────
security-audit:
name: Security Audit
if: >-
github.event_name != 'pull_request' ||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
github.event.label.name == 'ci:full'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Reject tracked runtime security configuration
run: pnpm check:security-artifacts
- name: Audit production dependencies (blocks on high/critical)
run: pnpm audit --prod --audit-level=high
- name: Audit all dependencies (informational)
run: pnpm audit
continue-on-error: true