656 lines
31 KiB
JavaScript
656 lines
31 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { afterEach, describe, it } from "vitest";
|
|
|
|
import { formatGithubCiWorkflowResult, verifyGithubCiWorkflow } from "./verify-github-ci-workflow.mjs";
|
|
|
|
const VALID_WORKFLOW = `name: CI
|
|
|
|
on:
|
|
push:
|
|
pull_request:
|
|
|
|
concurrency:
|
|
group: \${{ github.workflow }}-\${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
quality:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Prepare pnpm
|
|
run: corepack prepare pnpm@10.33.0 --activate
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
- name: Verify GitHub CI workflow
|
|
run: pnpm run verify:github-ci-workflow
|
|
- name: Verify Gitea CI workflow
|
|
run: pnpm run verify:gitea-ci-workflow
|
|
- name: Verify deploy workflow
|
|
run: pnpm run verify:deploy-workflow
|
|
- name: Verify deploy readiness
|
|
run: pnpm run verify:deploy-readiness
|
|
- name: Verify CI baseline
|
|
run: pnpm run verify:ci-baseline
|
|
- name: Verify source health
|
|
run: pnpm run verify:source-health
|
|
- name: Verify ESLint baseline
|
|
run: pnpm run verify:eslint-baseline
|
|
- name: Verify env drift
|
|
run: pnpm run verify:env-drift
|
|
- name: Verify health baseline
|
|
run: pnpm run verify:health-baseline
|
|
- name: Verify API inventory
|
|
run: pnpm run verify:api-inventory
|
|
- name: Verify tenant isolation report
|
|
run: pnpm run verify:tenant-isolation-report
|
|
- name: Verify sidebar routes
|
|
run: pnpm run verify:sidebar-routes
|
|
- name: Verify i18n
|
|
run: pnpm run verify:i18n
|
|
- name: Verify Tenant Auth baseline gates
|
|
run: pnpm run verify:tenant-auth-baseline
|
|
- name: Verify Tenant Auth onboarding flow
|
|
run: pnpm run verify:onboarding-flow
|
|
- name: Verify Phase C boundaries
|
|
run: pnpm run verify:phase-c-boundaries
|
|
- name: Verify Feature Manifest core without plugins
|
|
run: pnpm run verify:core-build-without-plugins-smoke
|
|
- name: Verify Feature Manifest removed modules
|
|
run: pnpm run verify:removed-modules
|
|
- name: Verify UI mock static baseline
|
|
run: pnpm run verify:ui-visual
|
|
- name: Test UI hot-swap
|
|
run: pnpm run test:hot-swap
|
|
- name: Verify accessibility coverage
|
|
run: pnpm run verify:a11y-coverage
|
|
- name: Test accessibility smoke
|
|
run: pnpm run test:a11y
|
|
- name: Verify docs
|
|
run: |
|
|
pnpm run verify:porting-docs-baseline
|
|
pnpm run verify:production-docs
|
|
pnpm run verify:release-docs
|
|
pnpm run verify:docs-links
|
|
- name: Typecheck
|
|
run: pnpm typecheck
|
|
- name: Lint
|
|
run: pnpm lint
|
|
- name: Foundation package tests
|
|
run: pnpm run test:foundation
|
|
- name: Package tests
|
|
run: pnpm run test:packages
|
|
- name: Mobile wrapper tests
|
|
run: pnpm run test:mobile
|
|
- name: Unit tests
|
|
run: pnpm test
|
|
- name: Build
|
|
run: pnpm run build:ci
|
|
- name: Verify bundle size
|
|
run: pnpm run verify:bundle-size
|
|
- name: Verify web Sentry sourcemaps
|
|
run: test -n "$(find apps/web/build/client -type f -name '*.map' -print -quit)"
|
|
- name: Upload web Sentry sourcemaps
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: web-client-sourcemaps
|
|
path: apps/web/build/client
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
- name: Verify production seed artifact
|
|
run: pnpm run verify:production-seed-artifact
|
|
api:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Prepare pnpm
|
|
run: corepack prepare pnpm@10.33.0 --activate
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
- name: Verify OpenAPI security
|
|
run: pnpm run verify:openapi-security
|
|
- name: Typecheck API
|
|
run: pnpm --filter @toolctrl/api typecheck
|
|
- name: Test API
|
|
run: pnpm exec vitest run apps/api/src
|
|
- name: Build API
|
|
run: pnpm --filter @toolctrl/api build
|
|
worker:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Prepare pnpm
|
|
run: corepack prepare pnpm@10.33.0 --activate
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
- name: Typecheck Worker
|
|
run: pnpm --filter @toolctrl/worker typecheck
|
|
- name: Test Worker
|
|
run: pnpm --filter @toolctrl/worker exec vitest run
|
|
- name: Build Worker
|
|
run: pnpm --filter @toolctrl/worker build
|
|
- name: Health Check Worker
|
|
run: pnpm --filter @toolctrl/worker health
|
|
database:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Prepare pnpm
|
|
run: corepack prepare pnpm@10.33.0 --activate
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
- name: Verify database migration status
|
|
run: pnpm run verify:db-migrate-status
|
|
env:
|
|
DATABASE_URL: \${{ secrets.DATABASE_URL }}
|
|
- name: Generate database client
|
|
run: pnpm run db:generate
|
|
env:
|
|
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
|
|
docker-smoke:
|
|
runs-on: ubuntu-latest
|
|
needs: [quality, api, worker, database]
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v3
|
|
- name: Build web Docker target
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
target: web
|
|
build-args: APP_REVISION=ci-smoke-revision
|
|
tags: toolctrl-web:smoke
|
|
load: true
|
|
push: false
|
|
cache-from: type=gha,scope=toolctrl-web-smoke
|
|
cache-to: type=gha,mode=max,scope=toolctrl-web-smoke
|
|
- name: Validate web Docker artifacts
|
|
run: docker run --rm --entrypoint /bin/sh toolctrl-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'
|
|
- name: Validate web Docker runtime
|
|
run: bash scripts/docker-web-runtime-smoke.sh toolctrl-web:smoke
|
|
- name: Build API Docker target
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
target: api
|
|
build-args: APP_REVISION=ci-smoke-revision
|
|
tags: toolctrl-api:smoke
|
|
load: true
|
|
push: false
|
|
cache-from: type=gha,scope=toolctrl-api-smoke
|
|
cache-to: type=gha,mode=max,scope=toolctrl-api-smoke
|
|
- name: Validate API Docker runtime
|
|
run: bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke
|
|
- name: Build worker Docker target
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
target: worker
|
|
build-args: APP_REVISION=ci-smoke-revision
|
|
tags: toolctrl-worker:smoke
|
|
load: true
|
|
push: false
|
|
cache-from: type=gha,scope=toolctrl-worker-smoke
|
|
cache-to: type=gha,mode=max,scope=toolctrl-worker-smoke
|
|
- name: Validate worker Docker runtime
|
|
run: bash scripts/docker-worker-runtime-smoke.sh toolctrl-worker:smoke
|
|
- name: Validate Docker Compose config
|
|
run: docker compose config
|
|
sentry-release-web:
|
|
needs: resolve-sentry-environment
|
|
uses: ./.github/workflows/_sentry-release.yml
|
|
with:
|
|
project: boilerplate-web
|
|
environment: preview
|
|
sourcemaps-path: apps/web/build/client
|
|
sourcemaps-artifact: web-client-sourcemaps
|
|
`;
|
|
|
|
const SENTRY_REUSABLE_WORKFLOW = `name: Sentry Release
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
sourcemaps-path:
|
|
required: false
|
|
type: string
|
|
default: "none"
|
|
sourcemaps-artifact:
|
|
required: false
|
|
type: string
|
|
default: "none"
|
|
|
|
jobs:
|
|
release:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Download source-map artifact
|
|
if: \${{ inputs.sourcemaps-artifact != 'none' }}
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
name: \${{ inputs.sourcemaps-artifact }}
|
|
path: \${{ inputs.sourcemaps-path }}
|
|
`;
|
|
|
|
const fixtures = [];
|
|
|
|
function writeFile(rootDir, relativePath, content) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content);
|
|
}
|
|
|
|
function createFixture(workflowContent = VALID_WORKFLOW) {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "github-ci-workflow-"));
|
|
fixtures.push(rootDir);
|
|
if (workflowContent !== null) {
|
|
writeFile(rootDir, ".github/workflows/ci.yml", workflowContent);
|
|
writeFile(rootDir, ".github/workflows/_sentry-release.yml", SENTRY_REUSABLE_WORKFLOW);
|
|
}
|
|
return rootDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-github-ci-workflow", () => {
|
|
it("passes for a valid GitHub CI workflow", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture());
|
|
const output = formatGithubCiWorkflowResult(result);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run verify:github-ci-workflow" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run verify:source-health" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run verify:eslint-baseline" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run verify:health-baseline" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run verify:tenant-auth-baseline" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run test:hot-swap" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: service job "api" does not need "quality"'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: service job "api" includes command "pnpm run verify:openapi-security"'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: service job "worker" does not need "quality"'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: database job "database" does not need "quality"'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" builds Docker target "worker"'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" loads Docker target "worker" for smoke validation'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" uses GitHub Actions cache for Docker target "worker"'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" does not run Corepack setup'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality command "pnpm run test:mobile" is ordered'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" validates web Docker artifacts'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" validates web Docker runtime'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" validates API Docker runtime'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Docker smoke job "docker-smoke" validates worker Docker runtime'));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: quality job uploads web source maps as artifact "web-client-sourcemaps"'));
|
|
assert.ok(result.passes.includes(".github/workflows/_sentry-release.yml: reusable workflow downloads requested source-map artifacts before upload"));
|
|
assert.ok(result.passes.includes('.github/workflows/ci.yml: Sentry web release consumes artifact "web-client-sourcemaps"'));
|
|
assert.ok(result.passes.includes(".github/workflows/ci.yml: no forbidden E2E/browser commands are present"));
|
|
assert.ok(result.passes.includes(".github/workflows/ci.yml: CI concurrency cancels superseded pull request and branch runs"));
|
|
assert.match(output, /^PASS GitHub CI workflow verification/);
|
|
});
|
|
|
|
it("fails when CI concurrency does not cancel superseded runs", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
/concurrency:\n group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}\n cancel-in-progress: true\n\n/u,
|
|
"",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /CI workflow must define concurrency by workflow plus PR\/ref with cancel-in-progress: true/);
|
|
});
|
|
|
|
it("fails when Docker smoke installs pnpm dependencies", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Set up Docker Buildx\n uses: docker/setup-buildx-action@v3\n",
|
|
" - name: Enable Corepack\n run: corepack enable\n - name: Prepare pnpm\n run: corepack prepare pnpm@10.33.0 --activate\n - name: Install dependencies\n run: pnpm install --frozen-lockfile\n - name: Set up Docker Buildx\n uses: docker/setup-buildx-action@v3\n",
|
|
),
|
|
),
|
|
);
|
|
|
|
const failures = result.failures.join("\n");
|
|
assert.match(failures, /Docker smoke job "docker-smoke" must not run Corepack setup/);
|
|
assert.match(failures, /Docker smoke job "docker-smoke" must not run "corepack prepare pnpm@10\.33\.0"/);
|
|
assert.match(failures, /Docker smoke job "docker-smoke" must not run "pnpm install --frozen-lockfile"/);
|
|
});
|
|
|
|
it("fails when the workflow file is missing", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(null));
|
|
|
|
assert.match(result.failures.join("\n"), /\.github\/workflows\/ci\.yml: missing required GitHub CI workflow file/);
|
|
});
|
|
|
|
it("fails when pnpm setup exists only outside the quality job", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" - name: Enable Corepack\n run: corepack enable\n", "")
|
|
.replace(" - name: Prepare pnpm\n run: corepack prepare pnpm@10.33.0 --activate\n", "")
|
|
.replace(" - name: Install dependencies\n run: pnpm install --frozen-lockfile\n", "")
|
|
.replace(" api:\n", " other:\n runs-on: ubuntu-latest\n steps:\n - name: Enable Corepack\n run: corepack enable\n - name: Prepare pnpm\n run: corepack prepare pnpm@10.33.0 --activate\n - name: Install dependencies\n run: pnpm install --frozen-lockfile\n api:\n"),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /quality job missing Corepack setup/);
|
|
assert.match(result.failures.join("\n"), /quality job must pin pnpm@10\.33\.0/);
|
|
assert.match(result.failures.join("\n"), /quality job missing "pnpm install --frozen-lockfile"/);
|
|
});
|
|
|
|
it("fails when a quality gate is missing from the quality job", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" - name: Verify Phase C boundaries\n run: pnpm run verify:phase-c-boundaries\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /quality job missing required command "pnpm run verify:phase-c-boundaries"/);
|
|
});
|
|
|
|
it("fails when a quality gate is duplicated in the quality job", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify source health\n run: pnpm run verify:source-health\n",
|
|
" - name: Verify source health\n run: pnpm run verify:source-health\n - name: Verify source health again\n run: pnpm run verify:source-health\n",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /quality command "pnpm run verify:source-health" must run exactly once to avoid redundant CI gates/);
|
|
});
|
|
|
|
it("fails when the health baseline gate is missing from the quality job", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" - name: Verify health baseline\n run: pnpm run verify:health-baseline\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /quality job missing required command "pnpm run verify:health-baseline"/);
|
|
});
|
|
|
|
it("fails when the source health gate is missing from the quality job", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" - name: Verify source health\n run: pnpm run verify:source-health\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /quality job missing required command "pnpm run verify:source-health"/);
|
|
});
|
|
|
|
it("fails when the ESLint baseline gate is missing from the quality job", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" - name: Verify ESLint baseline\n run: pnpm run verify:eslint-baseline\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /quality job missing required command "pnpm run verify:eslint-baseline"/);
|
|
});
|
|
|
|
it("fails when service commands are misplaced into another job", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" - name: Test API\n run: pnpm exec vitest run apps/api/src\n", "")
|
|
.replace(" - name: Build Worker\n run: pnpm --filter @toolctrl/worker build\n", " - name: Build Worker\n run: pnpm --filter @toolctrl/worker build\n - name: Misplaced API tests\n run: pnpm exec vitest run apps/api/src\n"),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /service job "api" missing command "pnpm exec vitest run apps\/api\/src"/);
|
|
});
|
|
|
|
it("fails when service jobs drop repository setup", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" - name: Checkout\n uses: actions/checkout@v4\n - name: Enable Corepack\n run: corepack enable\n - name: Prepare pnpm\n run: corepack prepare pnpm@10.33.0 --activate\n - name: Install dependencies\n run: pnpm install --frozen-lockfile\n - name: Verify OpenAPI security\n", " - name: Verify OpenAPI security\n"),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /service job "api" missing checkout step/);
|
|
assert.match(result.failures.join("\n"), /service job "api" missing Corepack setup/);
|
|
assert.match(result.failures.join("\n"), /service job "api" must pin pnpm@10\.33\.0/);
|
|
assert.match(result.failures.join("\n"), /service job "api" missing "pnpm install --frozen-lockfile"/);
|
|
});
|
|
|
|
it("accepts block-list needs syntax for Docker smoke fan-in", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" needs: [quality, api, worker, database]\n", " needs:\n - quality\n - api\n - worker\n - database\n"),
|
|
),
|
|
);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
});
|
|
|
|
it("fails when service jobs need quality", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" api:\n runs-on: ubuntu-latest\n", " api:\n runs-on: ubuntu-latest\n needs: quality\n")
|
|
.replace(" worker:\n runs-on: ubuntu-latest\n", " worker:\n runs-on: ubuntu-latest\n needs:\n - quality\n"),
|
|
),
|
|
);
|
|
|
|
const failures = result.failures.join("\n");
|
|
assert.match(failures, /service job "api" must not need "quality" so PR CI can run it in parallel/);
|
|
assert.match(failures, /service job "worker" must not need "quality" so PR CI can run it in parallel/);
|
|
});
|
|
|
|
it("fails when database job needs quality", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(VALID_WORKFLOW.replace(" database:\n runs-on: ubuntu-latest\n", " database:\n runs-on: ubuntu-latest\n needs: [quality]\n")),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /database job "database" must not need "quality" so PR CI can run it in parallel/);
|
|
});
|
|
|
|
it("fails when a required quality gate is softened at step level", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(VALID_WORKFLOW.replace(" - name: Lint\n run: pnpm lint\n", " - name: Lint\n if: always()\n run: pnpm lint\n")),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /quality command "pnpm lint" must not use continue-on-error or always\(\)/);
|
|
});
|
|
|
|
it("fails when a required quality gate masks shell failures in its run block", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(VALID_WORKFLOW.replace(" - name: Lint\n run: pnpm lint\n", " - name: Lint\n run: |\n set +e\n pnpm lint\n")),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /quality command "pnpm lint" must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when a service command is pass-opened with shell control operators", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(VALID_WORKFLOW.replace(" - name: Typecheck API\n run: pnpm --filter @toolctrl/api typecheck\n", " - name: Typecheck API\n run: pnpm --filter @toolctrl/api typecheck || true\n")),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /service job "api" command "pnpm --filter @toolctrl\/api typecheck" must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when Docker smoke validation ends with an unconditional true", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(VALID_WORKFLOW.replace(" - name: Validate Docker Compose config\n run: docker compose config\n", " - name: Validate Docker Compose config\n run: |\n docker compose config\n true\n")),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" command "docker compose config" must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when Docker smoke drops repository checkout", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" docker-smoke:\n runs-on: ubuntu-latest\n needs: [quality, api, worker, database]\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n",
|
|
" docker-smoke:\n runs-on: ubuntu-latest\n needs: [quality, api, worker, database]\n steps:\n",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" missing checkout step/);
|
|
});
|
|
|
|
it("fails when database secrets are exposed at job scope", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" database:\n runs-on: ubuntu-latest\n", " database:\n runs-on: ubuntu-latest\n env:\n DATABASE_URL: ${{ secrets.DATABASE_URL }}\n")));
|
|
|
|
assert.match(result.failures.join("\n"), /database job "database" must not set DATABASE_URL at job scope/);
|
|
});
|
|
|
|
it("fails when db:generate does not get a deterministic dummy database URL", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Generate database client\n run: pnpm run db:generate\n env:\n DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public\n",
|
|
" - name: Generate database client\n run: pnpm run db:generate\n",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /database job "database" must set DATABASE_URL: postgresql:\/\/toolctrl:toolctrl@localhost:5432\/toolctrl\?schema=public on the db:generate step/);
|
|
});
|
|
|
|
it("fails when Docker smoke publishes an image", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate Docker Compose config\n run: docker compose config\n",
|
|
" - name: Publish\n run: docker push registry.example.com/toolctrl/web:latest\n - name: Validate Docker Compose config\n run: docker compose config\n",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" contains forbidden publish command/);
|
|
});
|
|
|
|
it("fails when browser E2E commands are added to GitHub CI", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(`${VALID_WORKFLOW} - name: Browser E2E\n run: pnpm exec playwright test\n`));
|
|
|
|
assert.match(result.failures.join("\n"), /contains forbidden E2E\/browser command/);
|
|
});
|
|
|
|
it("fails when Docker smoke builds omit the revision build arg", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" build-args: APP_REVISION=ci-smoke-revision\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /\.github\/workflows\/ci\.yml: Docker smoke job "docker-smoke" build for target "web" must pass build-args: APP_REVISION=ci-smoke-revision/);
|
|
});
|
|
|
|
it("fails when Docker smoke builds omit GitHub Actions cache", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" cache-from: type=gha,scope=toolctrl-web-smoke\n", "").replace(" cache-to: type=gha,mode=max,scope=toolctrl-web-smoke\n", ""),
|
|
),
|
|
);
|
|
|
|
const failures = result.failures.join("\n");
|
|
assert.match(failures, /Docker smoke job "docker-smoke" build for target "web" must set cache-from: type=gha with scope=toolctrl-web-smoke/);
|
|
assert.match(failures, /Docker smoke job "docker-smoke" build for target "web" must set cache-to: type=gha,mode=max with scope=toolctrl-web-smoke/);
|
|
});
|
|
|
|
it("fails when Docker smoke builds do not load images for validation", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" load: true\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" build for target "web" must set load: true for smoke validation/);
|
|
});
|
|
|
|
it("fails when Docker smoke builds change smoke tags", () => {
|
|
const result = verifyGithubCiWorkflow(createFixture(VALID_WORKFLOW.replace(" tags: toolctrl-web:smoke\n", " tags: toolctrl-web:latest\n")));
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" build for target "web" must preserve tag "toolctrl-web:smoke"/);
|
|
});
|
|
|
|
it("fails when Docker smoke omits web artifact validation", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate web Docker artifacts\n run: docker run --rm --entrypoint /bin/sh toolctrl-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'\n",
|
|
"",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" missing command "docker run --rm --entrypoint \/bin\/sh toolctrl-web:smoke/);
|
|
});
|
|
|
|
it("fails when Docker smoke omits web runtime validation", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate web Docker runtime\n run: bash scripts/docker-web-runtime-smoke.sh toolctrl-web:smoke\n",
|
|
"",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" missing command "bash scripts\/docker-web-runtime-smoke\.sh toolctrl-web:smoke"/);
|
|
});
|
|
|
|
it("fails when Docker smoke omits API runtime validation", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate API Docker runtime\n run: bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke\n",
|
|
"",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" missing command "bash scripts\/docker-api-runtime-smoke\.sh toolctrl-api:smoke"/);
|
|
});
|
|
|
|
it("fails when Docker smoke omits worker runtime validation", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate worker Docker runtime\n run: bash scripts/docker-worker-runtime-smoke.sh toolctrl-worker:smoke\n",
|
|
"",
|
|
),
|
|
),
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" missing command "bash scripts\/docker-worker-runtime-smoke\.sh toolctrl-worker:smoke"/);
|
|
});
|
|
|
|
it("fails when web source maps are not uploaded for the Sentry release", () => {
|
|
const result = verifyGithubCiWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify web Sentry sourcemaps\n run: test -n \"$(find apps/web/build/client -type f -name '*.map' -print -quit)\"\n - name: Upload web Sentry sourcemaps\n uses: actions/upload-artifact@v4\n with:\n name: web-client-sourcemaps\n path: apps/web/build/client\n if-no-files-found: error\n retention-days: 7\n",
|
|
"",
|
|
),
|
|
),
|
|
);
|
|
|
|
const failures = result.failures.join("\n");
|
|
assert.match(failures, /quality job must validate web source-map files before Sentry artifact upload/);
|
|
assert.match(failures, /quality job must upload apps\/web\/build\/client as artifact "web-client-sourcemaps"/);
|
|
});
|
|
|
|
it("fails when the reusable Sentry workflow does not download source-map artifacts", () => {
|
|
const rootDir = createFixture();
|
|
writeFile(rootDir, ".github/workflows/_sentry-release.yml", "name: Sentry Release\n");
|
|
|
|
const result = verifyGithubCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.github\/workflows\/_sentry-release\.yml: reusable workflow must accept sourcemaps-artifact and download it to inputs\.sourcemaps-path/);
|
|
});
|
|
|
|
it("CLI exits non-zero and prints path-specific output for an invalid workflow", () => {
|
|
const rootDir = createFixture(null);
|
|
const scriptPath = path.resolve("scripts/verify-github-ci-workflow.mjs");
|
|
|
|
assert.throws(
|
|
() => execFileSync(process.execPath, [scriptPath], { cwd: rootDir, encoding: "utf8", stdio: "pipe" }),
|
|
(error) => {
|
|
assert.equal(error.status, 1);
|
|
assert.match(error.stderr, /^FAIL GitHub CI workflow verification/);
|
|
assert.match(error.stderr, /FAIL \.github\/workflows\/ci\.yml: missing required GitHub CI workflow file/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|