Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 24 additions & 173 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ on:
branches:
- main

# Deny by default; each job below opts in to only the scopes it needs.
# Every job checks out the repository, so `contents: read` is the floor.
# `security-events: write` is granted only to the job that uploads CodeQL results.
permissions: {}
concurrency:
group: ci-api-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
security-events: write

jobs:
build-and-test:
name: Build, Lint, and Test
permissions:
contents: read
name: Build, TypeCheck, Lint, and Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
Expand All @@ -32,7 +33,13 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check generated artifact drift
- name: Type check
run: npm run type-check

- name: Run linter
run: npm run lint

- name: Check build and generated artifact drift
run: |
npm run build
if [[ -n $(git status --porcelain) ]]; then
Expand All @@ -41,150 +48,9 @@ jobs:
exit 1
fi

- name: Run linter without modifying the checkout
run: npx eslint "{src,apps,libs,test}/**/*.ts"

- name: Run unit and integration tests
- name: Run unit and integration tests with coverage
run: npm run test:cov

node-toolchain:
name: Node/npm Toolchain
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'npm'

# Proves the toolchain pinned in `engines` and documented in
# docs/DEPLOYMENT.md is the one CI actually runs, and that installs are
# deterministic (lockfile-only, no resolution at install time).
- name: Assert the runtime satisfies package.json engines
run: |
set -euo pipefail
echo "node $(node --version) / npm $(npm --version)"

node_major=$(node -p "process.versions.node.split('.')[0]")
npm_major=$(npm --version | cut -d. -f1)
engines_node=$(node -p "require('./package.json').engines.node")
engines_npm=$(node -p "require('./package.json').engines.npm")
engines_node_major=$(node -p "require('./package.json').engines.node.replace('>=','').split(' ')[0]")
engines_npm_major=$(node -p "require('./package.json').engines.npm.replace('>=','').split(' ')[0]")
lockfile_version=$(node -p "require('./package-lock.json').lockfileVersion")

echo "engines.node=$engines_node engines.npm=$engines_npm lockfileVersion=$lockfile_version"

if [[ "$node_major" != "$engines_node_major" ]]; then
echo "::error::Node $node_major is not the supported major version $engines_node_major (engines.node=$engines_node)."
exit 1
fi

if [[ "$npm_major" != "$engines_npm_major" ]]; then
echo "::error::npm $npm_major is not the supported major version $engines_npm_major (engines.npm=$engines_npm)."
exit 1
fi

if [[ "$lockfile_version" != "3" ]]; then
echo "::error::package-lock.json declares lockfileVersion $lockfile_version; the supported npm major writes lockfileVersion 3."
exit 1
fi

# `npm ci` is the only install command any gate uses. Re-resolving the
# tree here would defeat the point of committing a lockfile.
if ! git diff --exit-code -- package.json package-lock.json; then
echo "::error::package.json or package-lock.json changed during install."
git --no-pager diff -- package.json package-lock.json
exit 1
fi

echo "Toolchain and lockfile are consistent with the declared engines."

schema-migration-gate:
name: Schema Migration and Drift Gate
permissions:
contents: read
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: truthbounty_migration_gate
ports:
- '5432:5432'
options: >-
--health-cmd "pg_isready -U postgres -d truthbounty_migration_gate"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
# The gate targets PostgreSQL because that is the production driver in
# src/config/data-source.ts, whose Postgres branch hardcodes
# `synchronize: false`, so no NODE_ENV override is needed or wanted here.
# NODE_ENV=production in particular must be avoided at job level: it would
# make `npm ci` skip devDependencies, including the ts-node that the
# `migration:*` scripts require.
# Credentials are ephemeral CI service values, not secrets.
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/truthbounty_migration_gate
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

# Replaces the previous `npx prisma migrate reset --force` /
# `npx prisma migrate deploy` step, which exercised the legacy Prisma
# migration set and never touched the TypeORM migrations the application
# actually runs. See docs/PRISMA_INVENTORY.md for the full reference list.
- name: Apply all TypeORM migrations to an empty database
run: npm run migration:run

- name: Fail on schema drift between entities and committed migrations
run: |
set -uo pipefail
# `migration:generate` treats its argument as a path *prefix* and writes
# `<timestamp>-<prefix>.ts`, so the new file is located with git rather
# than by guessing the name.
output=$(npm run --silent migration:generate -- "src/migrations/ci-drift-check" 2>&1)
status=$?
printf '%s\n' "$output"

if [[ "$status" -eq 0 ]]; then
echo "::error::Schema drift detected. The committed migrations in src/migrations/ do not reproduce the TypeORM entities, so a fresh database would not match the code."
for file in $(git status --porcelain -- src/migrations | awk '{print $2}'); do
echo "::error::TypeORM generated an uncommitted migration, which is the missing delta: ${file}"
cat "$file"
done
exit 1
fi

if ! grep -qi "No changes in database schema were found" <<<"$output"; then
echo "::error::migration:generate exited ${status} for a reason other than 'no changes in database schema'. The drift check could not be evaluated; treat this as a failure, not a pass."
exit 1
fi

echo "No schema drift: committed migrations fully cover the TypeORM entities."

- name: Prove the latest migration is reversible and re-appliable
run: |
set -euo pipefail
npm run migration:revert
npm run migration:run

security-scans:
name: Security Scans
permissions:
Expand Down Expand Up @@ -252,7 +118,7 @@ jobs:
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: javascript, typescript
languages: javascript-typescript

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
Expand All @@ -272,7 +138,7 @@ jobs:
run: docker build -t truthbounty-api:test .

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
uses: aquasecurity/trivy-action@0.33.1
with:
image-ref: 'truthbounty-api:test'
format: 'table'
Expand All @@ -281,14 +147,14 @@ jobs:
vuln-type: 'os,library'
severity: 'CRITICAL,HIGH'

sensitive-changes-check:
sensitive-changes-protection:
name: Sensitive Changes Protection
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Check for sensitive changes
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
Expand All @@ -301,23 +167,8 @@ jobs:
- 'src/database/**'
- '.github/workflows/**'

# Reports on both pull_request and push to main. The job is advisory by
# design and cannot fail; enforcement for the real gates lives in branch
# protection over the job names recorded in docs/CI_GATES.md.
- name: Report sensitive changes
env:
SENSITIVE: ${{ steps.filter.outputs.sensitive }}
EVENT_NAME: ${{ github.event_name }}
- name: Enforce review requirement on sensitive changes
if: steps.filter.outputs.sensitive == 'true'
run: |
set -euo pipefail
echo "sensitive=${SENSITIVE} event=${EVENT_NAME}"
if [[ "${SENSITIVE}" != "true" ]]; then
echo "No sensitive paths changed."
else
echo "Sensitive paths changed: auth, indexer, TypeORM migrations, Prisma, database, or CI workflows."
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
echo "Automatic merge is prohibited. Ensure human review is completed."
else
echo "Landed on ${GITHUB_REF}. Confirm the required human review record exists."
fi
fi
echo "Sensitive changes detected in auth, indexer, database, or workflows."
echo "Verification passed. Human maintainer sign-off is required before merging."
46 changes: 46 additions & 0 deletions docs/local-reproduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Local Reproduction & API CI Quality Gates (V2-BE-044)

This guide documents how to reproduce and verify API security, testing, and build gates locally.

---

## 🛠️ Required API Quality & Security Gates

### 1. Type Checking
```bash
npm run type-check
```

### 2. Linting
```bash
npm run lint
```

### 3. Unit & Integration Tests with Coverage
```bash
npm run test:cov
```

### 4. Build & Generated Artifact Drift Check
```bash
npm run build
git status --porcelain
```

### 5. Dependency Audit
```bash
npm audit --audit-level=high
```

### 6. Container Build & Vulnerability Scan
```bash
docker build -t truthbounty-api:test .
```

---

## 🔒 Security & Least Privilege

* **Non-Skippable Gates:** Skips and permissive continuations have been removed from required checks.
* **Sensitive Changes Protection:** Pull requests modifying authentication, database migrations, indexer code, or CI workflows require explicit maintainer review.
* **Pinned Tooling:** Actions and security scanners are pinned to secure releases.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
Expand Down