1000 lines
52 KiB
JavaScript
1000 lines
52 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 { formatGiteaCiWorkflowResult, verifyGiteaCiWorkflow } from "./verify-gitea-ci-workflow.mjs";
|
|
|
|
const VALID_WORKFLOW = `name: CI
|
|
|
|
on:
|
|
pull_request:
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: \${{ github.workflow }}-\${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
quality:
|
|
runs-on: toolctrl-ci
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Pin 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 Porting Docs baseline gates
|
|
run: pnpm run verify:porting-docs-baseline
|
|
- name: Verify Production Docs docs
|
|
run: pnpm run verify:production-docs
|
|
- name: Verify Release Docs release docs
|
|
run: pnpm run verify:release-docs
|
|
- name: Verify docs links
|
|
run: 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: toolctrl-ci
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Pin 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: toolctrl-ci
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Pin 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: Verify workflow outbox native readiness
|
|
run: pnpm run verify:workflow-outbox-native
|
|
- name: Build Worker
|
|
run: pnpm --filter @toolctrl/worker build
|
|
- name: Health Check Worker
|
|
run: pnpm --filter @toolctrl/worker health
|
|
database:
|
|
runs-on: toolctrl-ci
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
- name: Enable Corepack
|
|
run: corepack enable
|
|
- name: Pin pnpm
|
|
run: corepack prepare pnpm@10.33.0 --activate
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
- name: Generate database client
|
|
run: pnpm run db:generate
|
|
- name: Verify database migration status
|
|
run: pnpm run verify:db-migrate-status:staging
|
|
env:
|
|
COOLIFY_STAGING_SSH_HOST: \${{ vars.COOLIFY_STAGING_SSH_HOST }}
|
|
COOLIFY_STAGING_SSH_USER: \${{ vars.COOLIFY_STAGING_SSH_USER }}
|
|
COOLIFY_STAGING_WEB_RESOURCE_UUID: \${{ vars.COOLIFY_STAGING_WEB_RESOURCE_UUID }}
|
|
COOLIFY_STAGING_SSH_KEY: \${{ secrets.COOLIFY_STAGING_SSH_KEY }}
|
|
docker-smoke:
|
|
runs-on: toolctrl-ci
|
|
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 Docker smoke targets
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
docker build --target web --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-web:smoke . &
|
|
web_pid=$!
|
|
docker build --target api --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-api:smoke . &
|
|
api_pid=$!
|
|
docker build --target worker --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-worker:smoke . &
|
|
worker_pid=$!
|
|
status=0
|
|
wait "$web_pid" || status=$?
|
|
wait "$api_pid" || status=$?
|
|
wait "$worker_pid" || status=$?
|
|
exit "$status"
|
|
- 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: Validate API Docker runtime
|
|
run: bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke
|
|
env:
|
|
DOCKER_API_SMOKE_TRANSPORT: exec
|
|
- 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: ./.gitea/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: toolctrl-ci
|
|
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(), "gitea-ci-workflow-"));
|
|
fixtures.push(rootDir);
|
|
|
|
if (workflowContent !== null) {
|
|
writeFile(rootDir, ".gitea/workflows/ci.yml", workflowContent);
|
|
writeFile(rootDir, ".gitea/workflows/_sentry-release.yml", SENTRY_REUSABLE_WORKFLOW);
|
|
}
|
|
|
|
return rootDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-gitea-ci-workflow", () => {
|
|
it("passes for a valid minimal Gitea quality workflow with service jobs", () => {
|
|
const rootDir = createFixture();
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
const output = formatGiteaCiWorkflowResult(result);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: trigger \"push\" is disabled so deploy is not queued behind CI on main"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: trigger \"workflow_dispatch\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: pnpm@10.33.0 is pinned"));
|
|
assert.ok(result.passes.some((pass) => /^\.gitea\/workflows\/ci\.yml:\d+: runner is "toolctrl-ci"$/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:ci-baseline\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:source-health\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:eslint-baseline\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:github-ci-workflow\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:api-inventory\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:health-baseline\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:tenant-auth-baseline\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run verify:tenant-isolation-report\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: command \"pnpm run test:mobile\" is ordered"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"api\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"api\" includes scoped command \"pnpm run verify:openapi-security\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"api\" includes scoped command \"pnpm --filter @toolctrl/api typecheck\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"worker\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"worker\" includes scoped command \"pnpm run verify:workflow-outbox-native\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"worker\" includes scoped command \"pnpm --filter @toolctrl/worker build\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"worker\" includes scoped command \"pnpm --filter @toolctrl/worker health\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"api\" runs independently of \"quality\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: service job \"worker\" runs independently of \"quality\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" runs independently of \"quality\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" includes command \"pnpm run verify:db-migrate-status:staging\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" scopes staging SSH host to the migrate-status step"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" scopes staging SSH key to the migrate-status step"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" verifies private Coolify databases without exposing DATABASE_URL"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: database job \"database\" contains no deploy/seed/schema mutation commands"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" needs \"quality\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" needs \"database\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" does not run Corepack setup"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" builds Docker target \"web\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" builds Docker targets in parallel"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" validates web Docker artifacts"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" validates web Docker runtime"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" validates API Docker runtime"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" validates worker Docker runtime"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" validates Docker Compose config"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: web source maps are uploaded as artifact \"web-client-sourcemaps\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/_sentry-release.yml: reusable workflow downloads requested source-map artifacts before upload"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Sentry web release consumes artifact \"web-client-sourcemaps\""));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: Docker smoke job \"docker-smoke\" contains no registry login/push/publish commands"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: no forbidden E2E/browser commands are present"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/ci.yml: CI concurrency cancels superseded pull request and branch runs"));
|
|
assert.match(output, /^PASS Gitea CI workflow verification/);
|
|
assert.match(output, /Summary: \d+ passed, 0 observed, 0 failed/);
|
|
});
|
|
|
|
it("fails when CI concurrency does not cancel superseded runs", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
/concurrency:\n group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}\n cancel-in-progress: true\n\n/u,
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
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 rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Set up Docker Buildx\n",
|
|
" - name: Enable Corepack\n run: corepack enable\n - name: Pin 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",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
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 rootDir = createFixture(null);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required Gitea CI workflow file/);
|
|
});
|
|
|
|
it("fails when a required gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" run: pnpm lint\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm lint"/);
|
|
});
|
|
|
|
it("fails when pnpm setup is present only outside the quality job", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(" - name: Enable Corepack\n run: corepack enable\n", "")
|
|
.replace(" - name: Pin pnpm\n run: corepack prepare pnpm@10.33.0 --activate\n", "")
|
|
.replace(" - name: Install dependencies\n run: pnpm install --frozen-lockfile\n", ""),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
const failures = result.failures.join("\n");
|
|
|
|
assert.match(failures, /\.gitea\/workflows\/ci\.yml: missing Corepack setup/);
|
|
assert.match(failures, /\.gitea\/workflows\/ci\.yml: pnpm must be pinned to pnpm@10\.33\.0/);
|
|
assert.match(failures, /\.gitea\/workflows\/ci\.yml: missing required command "pnpm install --frozen-lockfile"/);
|
|
});
|
|
|
|
it("fails when a required quality gate only appears in a shell comment", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" run: pnpm run verify:docs-links\n", " run: |\n # pnpm run verify:docs-links\n echo skipped\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:docs-links"/);
|
|
});
|
|
|
|
it("fails when a required quality gate is duplicated", () => {
|
|
const rootDir = 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",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: command "pnpm run verify:source-health" must run exactly once to avoid redundant CI gates/);
|
|
});
|
|
|
|
it("fails when a required quality gate is pass-opened with shell control operators", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" run: pnpm lint\n", " run: pnpm lint || true\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: command "pnpm lint" must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when a required quality gate disables errexit in a run block", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" run: pnpm lint\n", " run: |\n set +e\n pnpm lint\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: command "pnpm lint" must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when a service command ends with an unconditional true", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Typecheck API\n run: pnpm --filter @toolctrl/api typecheck\n",
|
|
" - name: Typecheck API\n run: |\n pnpm --filter @toolctrl/api typecheck\n true\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "api" command "pnpm --filter @toolctrl\/api typecheck" must not use shell success masking/);
|
|
});
|
|
|
|
it("accepts block-list needs syntax for dependent jobs", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(" needs: [quality, api, worker, database]\n", " needs:\n - quality\n - api\n - worker\n - database\n"),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
});
|
|
|
|
it("fails when a required quality gate is softened at step level", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Lint\n run: pnpm lint\n", " - name: Lint\n continue-on-error: true\n run: pnpm lint\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /command "pnpm lint" must not use continue-on-error or always\(\)/);
|
|
});
|
|
|
|
it("fails when a required quality gate adds skip flags or suffixes", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" run: pnpm test\n", " run: pnpm test -- --passWithNoTests\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm test"/);
|
|
});
|
|
|
|
it("fails when worker tests are narrowed to explicit files", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" run: pnpm --filter @toolctrl/worker exec vitest run\n",
|
|
" run: pnpm --filter @toolctrl/worker exec vitest run src/handlers.test.ts src/health.test.ts",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "worker" missing scoped command "pnpm --filter @toolctrl\/worker exec vitest run"/);
|
|
});
|
|
|
|
it("fails when the worker service job omits the workflow outbox native readiness gate", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify workflow outbox native readiness\n run: pnpm run verify:workflow-outbox-native\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "worker" missing scoped command "pnpm run verify:workflow-outbox-native"/);
|
|
});
|
|
|
|
it("fails when the worker service job omits its health check", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Health Check Worker\n run: pnpm --filter @toolctrl/worker health\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "worker" missing scoped command "pnpm --filter @toolctrl\/worker health"/);
|
|
});
|
|
|
|
it("fails when the env drift gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify env drift\n run: pnpm run verify:env-drift\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:env-drift"/);
|
|
});
|
|
|
|
it("fails when the env drift gate runs after typecheck", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify env drift\n run: pnpm run verify:env-drift\n - name: Verify health baseline\n run: pnpm run verify:health-baseline\n - name: Verify API inventory\n run: pnpm run verify:api-inventory\n",
|
|
" - name: Verify health baseline\n run: pnpm run verify:health-baseline\n - name: Verify API inventory\n run: pnpm run verify:api-inventory\n - name: Verify env drift\n run: pnpm run verify:env-drift\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: command "pnpm run verify:health-baseline" must run after previous quality gates/);
|
|
});
|
|
|
|
it("fails when the health baseline gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify health baseline\n run: pnpm run verify:health-baseline\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:health-baseline"/);
|
|
});
|
|
|
|
it("fails when the source health gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify source health\n run: pnpm run verify:source-health\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:source-health"/);
|
|
});
|
|
|
|
it("fails when the ESLint baseline gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify ESLint baseline\n run: pnpm run verify:eslint-baseline\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:eslint-baseline"/);
|
|
});
|
|
|
|
it("fails when the API inventory gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify API inventory\n run: pnpm run verify:api-inventory\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:api-inventory"/);
|
|
});
|
|
|
|
it("fails when a service job command only appears in a shell comment", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Typecheck API\n run: pnpm --filter @toolctrl/api typecheck\n",
|
|
" - name: Typecheck API\n run: |\n # pnpm --filter @toolctrl/api typecheck\n echo skipped\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "api" missing scoped command "pnpm --filter @toolctrl\/api typecheck"/);
|
|
});
|
|
|
|
it("fails when the tenant isolation report gate is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify tenant isolation report\n run: pnpm run verify:tenant-isolation-report\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required command "pnpm run verify:tenant-isolation-report"/);
|
|
});
|
|
|
|
it("fails when the API inventory gate runs after typecheck", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify API inventory\n run: pnpm run verify:api-inventory\n - name: Verify tenant isolation report\n run: pnpm run verify:tenant-isolation-report\n - name: Verify sidebar routes\n run: pnpm run verify:sidebar-routes\n - name: Verify i18n\n run: pnpm run verify:i18n\n",
|
|
" - name: Verify tenant isolation report\n run: pnpm run verify:tenant-isolation-report\n - name: Verify sidebar routes\n run: pnpm run verify:sidebar-routes\n - name: Verify i18n\n run: pnpm run verify:i18n\n - name: Verify API inventory\n run: pnpm run verify:api-inventory\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: command "pnpm run verify:i18n" must run after previous quality gates/);
|
|
});
|
|
|
|
it("fails when a job uses ubuntu-latest instead of the required Gitea runner", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" runs-on: toolctrl-ci\n", " runs-on: ubuntu-latest\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
const output = formatGiteaCiWorkflowResult(result);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml:\d+: runs-on must be "toolctrl-ci" but found "ubuntu-latest"/);
|
|
assert.match(output, /^FAIL Gitea CI workflow verification/);
|
|
});
|
|
|
|
it("fails when lint runs before typecheck", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Typecheck\n run: pnpm typecheck\n - name: Lint\n run: pnpm lint\n",
|
|
" - name: Lint\n run: pnpm lint\n - name: Typecheck\n run: pnpm typecheck\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: command "pnpm lint" must run after previous quality gates/);
|
|
});
|
|
|
|
it("fails when a service job is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(/ worker:\n[\s\S]*$/, ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required service job "worker"/);
|
|
});
|
|
|
|
it("fails when a service job waits for the quality job", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" api:\n runs-on: toolctrl-ci\n steps:\n", " api:\n runs-on: toolctrl-ci\n needs: quality\n steps:\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "api" must not wait for "quality"; scoped service checks should run in parallel/);
|
|
assert.doesNotMatch(result.failures.join("\n"), /service job "worker" must not wait for "quality"/);
|
|
});
|
|
|
|
it("fails when a scoped service command is missing from the owning job", () => {
|
|
const rootDir = 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",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "api" missing scoped command "pnpm exec vitest run apps\/api\/src"/);
|
|
assert.doesNotMatch(result.failures.join("\n"), /service job "worker" missing scoped command "pnpm exec vitest run apps\/api\/src"/);
|
|
});
|
|
|
|
it("fails when the API service job omits the OpenAPI security guard", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify OpenAPI security\n run: pnpm run verify:openapi-security\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: service job "api" missing scoped command "pnpm run verify:openapi-security"/);
|
|
});
|
|
|
|
it("fails when the database job is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(/ database:\n[\s\S]*? docker-smoke:/, " docker-smoke:"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required database job "database"/);
|
|
});
|
|
|
|
it("fails when the database job waits for the quality job", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" database:\n runs-on: toolctrl-ci\n steps:\n", " database:\n runs-on: toolctrl-ci\n needs: quality\n steps:\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" must not wait for "quality"; database verification should run in parallel/);
|
|
});
|
|
|
|
it("fails when the database job omits migrate status verification", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n env:\n COOLIFY_STAGING_SSH_HOST: ${{ vars.COOLIFY_STAGING_SSH_HOST }}\n COOLIFY_STAGING_SSH_USER: ${{ vars.COOLIFY_STAGING_SSH_USER }}\n COOLIFY_STAGING_WEB_RESOURCE_UUID: ${{ vars.COOLIFY_STAGING_WEB_RESOURCE_UUID }}\n COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" missing command "pnpm run verify:db-migrate-status:staging"/);
|
|
});
|
|
|
|
it("fails when the database job omits the step-level staging SSH key secret", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" must set COOLIFY_STAGING_SSH_KEY: \$\{\{ secrets\.COOLIFY_STAGING_SSH_KEY \}\} on the migrate-status step/);
|
|
});
|
|
|
|
it("fails when the database job omits the step-level staging host variable", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" COOLIFY_STAGING_SSH_HOST: ${{ vars.COOLIFY_STAGING_SSH_HOST }}\n", ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" must set COOLIFY_STAGING_SSH_HOST: \$\{\{ vars\.COOLIFY_STAGING_SSH_HOST \}\} on the migrate-status step/);
|
|
});
|
|
|
|
it("fails when the database job exposes DATABASE_URL at job scope", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" database:\n runs-on: toolctrl-ci\n steps:\n - name: Checkout\n", " database:\n runs-on: toolctrl-ci\n env:\n DATABASE_URL: ${{ secrets.DATABASE_URL }}\n steps:\n - name: Checkout\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" must not set DATABASE_URL at job scope/);
|
|
});
|
|
|
|
it("fails when the database job uses the direct DATABASE_URL secret", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}\n", " COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}\n DATABASE_URL: ${{ secrets.DATABASE_URL }}\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" must not require direct DATABASE_URL access to private Coolify databases/);
|
|
});
|
|
|
|
it("fails when the database job contains a migration deploy command", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n", " - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n - name: Deploy migrations\n run: pnpm --filter @toolctrl/db prisma migrate deploy\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" contains forbidden deploy\/seed\/schema mutation command: run: pnpm --filter @toolctrl\/db prisma migrate deploy/);
|
|
});
|
|
|
|
it("fails when the database job hides a migration deploy command in a multiline run block", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n",
|
|
" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n - name: Deploy migrations\n run: |\n pnpm run db:generate\n pnpm --filter @toolctrl/db prisma migrate deploy\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" contains forbidden deploy\/seed\/schema mutation command: run: pnpm run db:generate\npnpm --filter @toolctrl\/db prisma migrate deploy/);
|
|
});
|
|
|
|
it("fails when the database job contains an E2E browser command", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n", " - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n - name: Browser check\n run: pnpm exec playwright test\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" contains forbidden E2E\/browser command: run: pnpm exec playwright test/);
|
|
});
|
|
|
|
it("fails when the database job hides an E2E browser command in a multiline run block", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n",
|
|
" - name: Verify database migration status\n run: pnpm run verify:db-migrate-status:staging\n - name: Browser check\n run: |\n pnpm run db:generate\n pnpm exec playwright test\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: database job "database" contains forbidden E2E\/browser command: run: pnpm run db:generate\npnpm exec playwright test/);
|
|
});
|
|
|
|
it("fails when the Docker smoke job is missing", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(/ docker-smoke:\n[\s\S]*$/, ""));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: missing required Docker smoke job "docker-smoke"/);
|
|
});
|
|
|
|
it("fails when the Docker smoke job is missing a required dependency", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" needs: [quality, api, worker, database]\n", " needs: [quality, api]\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: Docker smoke job "docker-smoke" must declare needs including "worker"/);
|
|
assert.doesNotMatch(result.failures.join("\n"), /must declare needs including "quality"/);
|
|
});
|
|
|
|
it("fails when the Docker smoke job does not depend on the database job", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace(" needs: [quality, api, worker, database]\n", " needs: [quality, api, worker]\n"));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: Docker smoke job "docker-smoke" must declare needs including "database"/);
|
|
});
|
|
|
|
it("fails when a Docker target build is missing from the Docker smoke job", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(" - name: Build API\n run: pnpm --filter @toolctrl/api build\n", " - name: Build API\n run: pnpm --filter @toolctrl/api build\n - name: Misplaced Docker API build\n run: docker buildx build --target api --file Dockerfile .\n").replace(
|
|
" docker build --target api --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-api:smoke . &\n api_pid=$!\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: Docker smoke job "docker-smoke" missing Docker build for target "api"/);
|
|
assert.doesNotMatch(result.failures.join("\n"), /missing Docker build for target "web"/);
|
|
});
|
|
|
|
it("fails when a Docker smoke build omits the revision build arg", () => {
|
|
const rootDir = createFixture(VALID_WORKFLOW.replace("docker build --target web --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-web:smoke .", "docker build --target web --tag toolctrl-web:smoke ."));
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: Docker smoke job "docker-smoke" build for target "web" must pass --build-arg APP_REVISION=ci-smoke-revision/);
|
|
});
|
|
|
|
it("fails when the Docker smoke job contains a forbidden publish command", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate Docker Compose config\n run: docker compose config\n",
|
|
" - name: Validate Docker Compose config\n run: docker compose config\n - name: Publish image\n run: docker push registry.example.com/toolctrl/api:latest\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: Docker smoke job "docker-smoke" contains forbidden publish command: run: docker push registry\.example\.com\/toolctrl\/api:latest/);
|
|
});
|
|
|
|
it("fails when the Docker smoke job omits web artifact validation", () => {
|
|
const rootDir = 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",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
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 the Docker smoke job omits web runtime validation", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate web Docker runtime\n run: bash scripts/docker-web-runtime-smoke.sh toolctrl-web:smoke\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
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 the Docker smoke job omits API runtime validation", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate API Docker runtime\n run: bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke\n env:\n DOCKER_API_SMOKE_TRANSPORT: exec\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
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 the Docker smoke job omits worker runtime validation", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate worker Docker runtime\n run: bash scripts/docker-worker-runtime-smoke.sh toolctrl-worker:smoke\n",
|
|
"",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
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 Docker smoke target builds are not parallelized", () => {
|
|
const sequentialBuilds = ` - name: Build web Docker target
|
|
run: docker build --target web --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-web:smoke .
|
|
- name: Build api Docker target
|
|
run: docker build --target api --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-api:smoke .
|
|
- name: Build worker Docker target
|
|
run: docker build --target worker --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-worker:smoke .
|
|
`;
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(/ - name: Build Docker smoke targets\n shell: bash\n run: \|\n(?: .*\n)+? - name: Validate web Docker artifacts\n/, `${sequentialBuilds} - name: Validate web Docker artifacts\n`),
|
|
);
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /Docker smoke job "docker-smoke" must build web, api, and worker Docker targets in one parallel bash step/);
|
|
assert.doesNotMatch(result.failures.join("\n"), /missing Docker build for target "api"/);
|
|
});
|
|
|
|
it("fails when the Docker smoke job hides a forbidden publish command in a multiline run block", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Validate Docker Compose config\n run: docker compose config\n",
|
|
" - name: Validate Docker Compose config\n run: docker compose config\n - name: Publish image\n run: |\n docker compose config\n docker push registry.example.com/toolctrl/api:latest\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: Docker smoke job "docker-smoke" contains forbidden publish command: run: docker compose config\ndocker push registry\.example\.com\/toolctrl\/api:latest/);
|
|
});
|
|
|
|
it("does not treat Docker publish commands outside Docker smoke as Docker smoke publish failures", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Build API\n run: pnpm --filter @toolctrl/api build\n",
|
|
" - name: Build API\n run: pnpm --filter @toolctrl/api build\n - name: Misplaced publish\n run: docker push registry.example.com/toolctrl/api:latest\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.doesNotMatch(result.failures.join("\n"), /Docker smoke job "docker-smoke" contains forbidden publish command/);
|
|
});
|
|
|
|
it("fails when a forbidden Playwright command is present", () => {
|
|
const rootDir = createFixture(`${VALID_WORKFLOW} - name: E2E\n run: pnpm exec playwright test\n`);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: forbidden E2E\/browser command found: run: pnpm exec playwright test/);
|
|
});
|
|
|
|
it("fails when a forbidden Playwright command is hidden in a multiline run block", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Build\n run: pnpm run build:ci\n",
|
|
" - name: Build\n run: |\n pnpm run build:ci\n pnpm exec playwright test\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: forbidden E2E\/browser command found: run: pnpm run build:ci\npnpm exec playwright test/);
|
|
});
|
|
|
|
it("fails when a forbidden browser command is present in a service job", () => {
|
|
const rootDir = createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Build API\n run: pnpm --filter @toolctrl/api build\n",
|
|
" - name: Build API\n run: pnpm --filter @toolctrl/api build\n - name: Browser smoke\n run: pnpm --filter @toolctrl/api exec browser test\n",
|
|
),
|
|
);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/ci\.yml: forbidden E2E\/browser command found: run: pnpm --filter @toolctrl\/api exec browser test/);
|
|
});
|
|
|
|
it("fails when the internal host override uses the reserved GITEA_ prefix", () => {
|
|
const rootDir = createFixture(`env:\n GITEA_HOST_IP: \${{ vars.GITEA_HOST_IP }}\n${VALID_WORKFLOW}`);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /GITEA_HOST_IP is forbidden because Gitea reserves the GITEA_ variable prefix/);
|
|
});
|
|
|
|
it("fails when the internal host override is written without token validation", () => {
|
|
const rootDir = createFixture(`${VALID_WORKFLOW}\n host-smoke:\n runs-on: toolctrl-ci\n steps:\n - name: Resolve internal host\n run: |\n if [ -n \"\${TOOLCTRL_GITEA_HOST_IP:-}\" ]; then\n printf '%s git.tool-ctrl.de\\n' \"$TOOLCTRL_GITEA_HOST_IP\" >> /etc/hosts\n fi\n`);
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /TOOLCTRL_GITEA_HOST_IP must be validated before writing to \/etc\/hosts/);
|
|
});
|
|
|
|
it("fails when web source maps are not uploaded for the Sentry release", () => {
|
|
const rootDir = 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 result = verifyGiteaCiWorkflow(rootDir);
|
|
const failures = result.failures.join("\n");
|
|
|
|
assert.match(failures, /missing web source-map validation before Sentry artifact upload/);
|
|
assert.match(failures, /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, ".gitea/workflows/_sentry-release.yml", "name: Sentry Release\n");
|
|
|
|
const result = verifyGiteaCiWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/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-gitea-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 Gitea CI workflow verification/);
|
|
assert.match(error.stderr, /FAIL \.gitea\/workflows\/ci\.yml: missing required Gitea CI workflow file/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|