feat: runtime config + Dockerfiles + CI overhaul
CI / sast (push) Successful in 19s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 20s
CI / lint (push) Failing after 5s
CI / test (push) Failing after 7s
CI / image-scan (push) Has been skipped
CI / build-images (push) Has been skipped
CI / push (push) Has been skipped
CI / sast (push) Successful in 19s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 20s
CI / lint (push) Failing after 5s
CI / test (push) Failing after 7s
CI / image-scan (push) Has been skipped
CI / build-images (push) Has been skipped
CI / push (push) Has been skipped
Three coupled changes the deployable build pipeline needed:
1. Runtime config (window.__APP_CONFIG__)
Vite bakes env vars at build time, which means one image per
deploy target. Switched to a runtime-config pattern: the nginx
container's entrypoint writes /config.js from env vars before
serving, and index.html loads it before the React bundle. The
client reads window.__APP_CONFIG__.apiBaseUrl via the new
runtimeConfig.apiUrl() helper instead of hardcoding /api. One
image, any backend.
2. Dockerfiles
- packages/server/Dockerfile: multi-stage with `pnpm deploy
--prod` for a flat node_modules. tini for PID 1, non-root
user, alpine base. The `.prisma/client/` generated artifacts
aren't tracked by `pnpm deploy` (not a declared dep) so we
manually copy them from the builder's pnpm store into the
same .pnpm@prisma+client+... directory that deploy preserved
— without this, PrismaClient instantiation throws "did not
initialize yet."
- packages/client/Dockerfile: vite build + nginx:alpine
runtime, custom nginx.conf with no-cache on /config.js and
long-cache on hashed assets, /docker-entrypoint.d/40-app-
config.sh materializes config.js at startup.
- Added tsconfig.build.json on the server so `nest build` emits
dist/main.js (the existing tsconfig included test/, which
made tsc pick the package root as common prefix and produce
dist/src/main.js).
3. CI overhaul (.github/workflows/ci.yml)
Switched to the per-project pattern used elsewhere: parallel
test / lint / secrets-scan / vuln-scan / sast jobs, then
build-images, image-scan, and a gated push job. Push is
main-only, checks Harbor for an existing version tag before
pushing, signs images with cosign, and back-pushes per-package
git tags (server-vX.Y.Z, client-vX.Y.Z). Renamed Harbor
project to tehriehlincremental and the auth secrets to
INCREMENTAL_USERNAME / INCREMENTAL_PASSWORD per request. Test
job retains the Postgres service container so integration
tests still run.
Verification:
- Server image: builds, boots, mounts all routes, "listening on
:3001" — confirmed Prisma client initializes properly.
- Client image: builds, entrypoint generates /config.js with
APP_API_BASE_URL=https://api.example.com/api confirmed via
curl, index.html loads config.js before main bundle.
- pnpm -r typecheck / test / lint / format:check: all clean.
- 80 unit tests pass + 7 integration tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/coverage
|
||||
**/.turbo
|
||||
**/*.log
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.swp
|
||||
.docker-data
|
||||
+376
-84
@@ -2,59 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
PNPM_VERSION: '9.12.0'
|
||||
|
||||
jobs:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Static analysis. Runs in parallel with the test job — faster signal on lint
|
||||
# / typecheck failures than waiting for Postgres to come up.
|
||||
# -----------------------------------------------------------------------------
|
||||
static:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prisma generate
|
||||
run: pnpm --filter @teh-riehl/server prisma generate
|
||||
|
||||
- name: Lint
|
||||
run: pnpm -r lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Build
|
||||
run: pnpm -r build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests, including the Postgres-backed server integration suite.
|
||||
# The coverage threshold gate fires here — if shared/server branches drop
|
||||
# below 90/85 respectively, CI fails.
|
||||
# -----------------------------------------------------------------------------
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -81,56 +31,398 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Generate scratch crypto keys
|
||||
# 32-byte KEK / EHK and 64-byte JWT secrets, fresh per run. These are
|
||||
# CI-only. Production keys live in the deploy environment, never the repo.
|
||||
run: |
|
||||
echo "ENCRYPTION_MASTER_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
|
||||
echo "EMAIL_HASH_SECRET=$(openssl rand -base64 32)" >> $GITHUB_ENV
|
||||
echo "JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n')" >> $GITHUB_ENV
|
||||
echo "JWT_REFRESH_SECRET=$(openssl rand -base64 64 | tr -d '\n')" >> $GITHUB_ENV
|
||||
|
||||
- name: Install
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prisma generate
|
||||
run: pnpm --filter @teh-riehl/server prisma generate
|
||||
- name: Generate Prisma client
|
||||
run: pnpm --filter @teh-riehl/server exec prisma generate
|
||||
|
||||
- name: Prisma migrate (deploy)
|
||||
run: pnpm --filter @teh-riehl/server prisma migrate deploy
|
||||
- name: Apply Prisma migrations
|
||||
run: pnpm --filter @teh-riehl/server exec prisma migrate deploy
|
||||
|
||||
- name: Unit tests (all packages, with coverage gate)
|
||||
run: pnpm -r test:coverage
|
||||
- name: Generate scratch crypto secrets
|
||||
# CI-only random values. Production secrets live in the deploy
|
||||
# environment, never in the repo or CI logs.
|
||||
run: |
|
||||
echo "ENCRYPTION_MASTER_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV"
|
||||
echo "EMAIL_HASH_SECRET=$(openssl rand -base64 32)" >> "$GITHUB_ENV"
|
||||
echo "JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n')" >> "$GITHUB_ENV"
|
||||
echo "JWT_REFRESH_SECRET=$(openssl rand -base64 64 | tr -d '\n')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Unit tests (all packages)
|
||||
run: pnpm -r test
|
||||
|
||||
- name: Integration tests (server + real Postgres)
|
||||
run: pnpm --filter @teh-riehl/server test:integration
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Secrets + dependency scans. Run on every commit; fail loudly on findings.
|
||||
# -----------------------------------------------------------------------------
|
||||
security:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma client
|
||||
# Server ESLint imports types from the generated Prisma client.
|
||||
run: pnpm --filter @teh-riehl/server exec prisma generate
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
|
||||
secrets-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # gitleaks needs history to scan PR-only diffs
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gitleaks (secrets scan)
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
||||
-o /tmp/gitleaks.tar.gz
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
sudo mv /tmp/gitleaks /usr/local/bin/gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Trivy (dependency + filesystem scan)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
- name: Run gitleaks
|
||||
# TODO: flip to --exit-code 1 once the baseline is clean
|
||||
run: |
|
||||
gitleaks detect \
|
||||
--source . \
|
||||
--report-format sarif \
|
||||
--report-path gitleaks.sarif \
|
||||
--redact \
|
||||
--no-git \
|
||||
--exit-code 0
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
scan-type: fs
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
exit-code: '1'
|
||||
name: gitleaks-report
|
||||
path: gitleaks.sarif
|
||||
|
||||
vuln-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Trivy filesystem scan (vuln + misconfig)
|
||||
# TODO: flip --exit-code to 1 once baseline is clean
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/src" aquasec/trivy:latest \
|
||||
fs /src \
|
||||
--severity HIGH,CRITICAL \
|
||||
--scanners vuln,misconfig \
|
||||
--format sarif \
|
||||
--output /src/trivy-fs.sarif \
|
||||
--exit-code 0
|
||||
|
||||
- name: Trivy SBOM (CycloneDX)
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/src" aquasec/trivy:latest \
|
||||
fs /src \
|
||||
--format cyclonedx \
|
||||
--output /src/sbom.cdx.json
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: trivy-reports
|
||||
path: |
|
||||
trivy-fs.sarif
|
||||
sbom.cdx.json
|
||||
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Semgrep
|
||||
# TODO: drop the trailing "|| true" once the baseline is clean
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/src" returntocorp/semgrep:latest \
|
||||
semgrep scan \
|
||||
--config p/typescript \
|
||||
--config p/react \
|
||||
--config p/nodejsscan \
|
||||
--config p/owasp-top-ten \
|
||||
--sarif --output /src/semgrep.sarif \
|
||||
--error /src || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: semgrep-report
|
||||
path: semgrep.sarif
|
||||
|
||||
build-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint, secrets-scan, vuln-scan, sast]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute image tags
|
||||
run: |
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
SERVER_VERSION=$(jq -r .version packages/server/package.json)
|
||||
CLIENT_VERSION=$(jq -r .version packages/client/package.json)
|
||||
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
||||
echo "SERVER_VERSION=${SERVER_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "CLIENT_VERSION=${CLIENT_VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build server image
|
||||
run: |
|
||||
docker buildx build --load \
|
||||
-f packages/server/Dockerfile \
|
||||
-t tehriehlincremental-server:${SHA_SHORT} \
|
||||
.
|
||||
|
||||
- name: Build client image
|
||||
run: |
|
||||
docker buildx build --load \
|
||||
-f packages/client/Dockerfile \
|
||||
-t tehriehlincremental-client:${SHA_SHORT} \
|
||||
.
|
||||
|
||||
- name: Save images for downstream jobs
|
||||
run: |
|
||||
docker save \
|
||||
tehriehlincremental-server:${SHA_SHORT} \
|
||||
tehriehlincremental-client:${SHA_SHORT} \
|
||||
-o /tmp/images.tar
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: built-images
|
||||
path: /tmp/images.tar
|
||||
retention-days: 1
|
||||
|
||||
image-scan:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-images
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: built-images
|
||||
path: /tmp
|
||||
|
||||
- name: Load images
|
||||
run: |
|
||||
docker load -i /tmp/images.tar
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Trivy image scan — server (HIGH/CRITICAL gate)
|
||||
run: |
|
||||
# Print findings table to the job log first so the cause of any
|
||||
# failure is visible inline (the SARIF run below writes only to file).
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--format table \
|
||||
tehriehlincremental-server:${SHA_SHORT} || true
|
||||
# Real gate + SARIF artifact.
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PWD:/src" \
|
||||
aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
--format sarif --output /src/trivy-image-server.sarif \
|
||||
tehriehlincremental-server:${SHA_SHORT}
|
||||
|
||||
- name: Trivy image scan — client (HIGH/CRITICAL gate)
|
||||
if: always()
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--format table \
|
||||
tehriehlincremental-client:${SHA_SHORT} || true
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PWD:/src" \
|
||||
aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
--format sarif --output /src/trivy-image-client.sarif \
|
||||
tehriehlincremental-client:${SHA_SHORT}
|
||||
|
||||
- name: Trivy SBOM — server
|
||||
if: always()
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PWD:/src" \
|
||||
aquasec/trivy:latest image \
|
||||
--format cyclonedx --output /src/sbom-server.cdx.json \
|
||||
tehriehlincremental-server:${SHA_SHORT}
|
||||
|
||||
- name: Trivy SBOM — client
|
||||
if: always()
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PWD:/src" \
|
||||
aquasec/trivy:latest image \
|
||||
--format cyclonedx --output /src/sbom-client.cdx.json \
|
||||
tehriehlincremental-client:${SHA_SHORT}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: trivy-image-reports
|
||||
if-no-files-found: warn
|
||||
path: |
|
||||
trivy-image-server.sarif
|
||||
trivy-image-client.sarif
|
||||
sbom-server.cdx.json
|
||||
sbom-client.cdx.json
|
||||
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: image-scan
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
HARBOR_HOST: harbor.tehriehldeal.com
|
||||
HARBOR_PROJECT: tehriehlincremental
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: built-images
|
||||
path: /tmp
|
||||
|
||||
- name: Load images and compute tag inputs
|
||||
run: |
|
||||
docker load -i /tmp/images.tar
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
SERVER_VERSION=$(jq -r .version packages/server/package.json)
|
||||
CLIENT_VERSION=$(jq -r .version packages/client/package.json)
|
||||
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
||||
echo "SERVER_VERSION=${SERVER_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "CLIENT_VERSION=${CLIENT_VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Refuse to overwrite an existing version tag
|
||||
env:
|
||||
HARBOR_USERNAME: ${{ secrets.INCREMENTAL_USERNAME }}
|
||||
HARBOR_PASSWORD: ${{ secrets.INCREMENTAL_PASSWORD }}
|
||||
run: |
|
||||
set -eu
|
||||
check_tag() {
|
||||
local svc="$1" ver="$2"
|
||||
local url="https://${HARBOR_HOST}/api/v2.0/projects/${HARBOR_PROJECT}/repositories/${svc}/artifacts/${ver}/tags"
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -u "${HARBOR_USERNAME}:${HARBOR_PASSWORD}" "${url}")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "::error::Tag ${HARBOR_PROJECT}/${svc}:${ver} already exists in Harbor. Bump ${svc}/package.json before merging."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$code" != "404" ]; then
|
||||
echo "::warning::Unexpected status ${code} checking ${url} — proceeding."
|
||||
fi
|
||||
}
|
||||
check_tag server "${SERVER_VERSION}"
|
||||
check_tag client "${CLIENT_VERSION}"
|
||||
|
||||
- name: Log in to Harbor
|
||||
env:
|
||||
HARBOR_USERNAME: ${{ secrets.INCREMENTAL_USERNAME }}
|
||||
HARBOR_PASSWORD: ${{ secrets.INCREMENTAL_PASSWORD }}
|
||||
run: |
|
||||
echo "${HARBOR_PASSWORD}" | docker login "${HARBOR_HOST}" -u "${HARBOR_USERNAME}" --password-stdin
|
||||
|
||||
- name: Tag and push server image
|
||||
run: |
|
||||
REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/server"
|
||||
docker tag tehriehlincremental-server:${SHA_SHORT} ${REPO}:${SERVER_VERSION}
|
||||
docker tag tehriehlincremental-server:${SHA_SHORT} ${REPO}:${SHA_SHORT}
|
||||
docker tag tehriehlincremental-server:${SHA_SHORT} ${REPO}:latest
|
||||
docker push ${REPO}:${SERVER_VERSION}
|
||||
docker push ${REPO}:${SHA_SHORT}
|
||||
docker push ${REPO}:latest
|
||||
|
||||
- name: Tag and push client image
|
||||
run: |
|
||||
REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/client"
|
||||
docker tag tehriehlincremental-client:${SHA_SHORT} ${REPO}:${CLIENT_VERSION}
|
||||
docker tag tehriehlincremental-client:${SHA_SHORT} ${REPO}:${SHA_SHORT}
|
||||
docker tag tehriehlincremental-client:${SHA_SHORT} ${REPO}:latest
|
||||
docker push ${REPO}:${CLIENT_VERSION}
|
||||
docker push ${REPO}:${SHA_SHORT}
|
||||
docker push ${REPO}:latest
|
||||
|
||||
- name: Install cosign
|
||||
run: |
|
||||
curl -fsSL "https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64" \
|
||||
-o /usr/local/bin/cosign
|
||||
chmod +x /usr/local/bin/cosign
|
||||
cosign version
|
||||
|
||||
- name: Sign images with cosign
|
||||
env:
|
||||
COSIGN_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
set -eu
|
||||
# Sign by SHA tag — cosign resolves to the underlying digest, so one
|
||||
# signature covers every tag pointing at the same digest (version,
|
||||
# sha, latest). Harbor looks up signatures by digest.
|
||||
cosign sign --key env://COSIGN_KEY --yes \
|
||||
"${HARBOR_HOST}/${HARBOR_PROJECT}/server:${SHA_SHORT}"
|
||||
cosign sign --key env://COSIGN_KEY --yes \
|
||||
"${HARBOR_HOST}/${HARBOR_PROJECT}/client:${SHA_SHORT}"
|
||||
|
||||
- name: Push back per-package git tags
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@tehriehldeal.com"
|
||||
git tag "server-v${SERVER_VERSION}" -m "server ${SERVER_VERSION}" 2>/dev/null || true
|
||||
git tag "client-v${CLIENT_VERSION}" -m "client ${CLIENT_VERSION}" 2>/dev/null || true
|
||||
ORIGIN="$(git remote get-url origin | sed -E "s#https?://([^/]+)#https://${GITEA_TOKEN}@\1#")"
|
||||
# A failed tag push shouldn't undo a successful image push — log
|
||||
# and proceed so we don't poison subsequent retries.
|
||||
git push "$ORIGIN" --tags || echo "::warning::Tag push to origin failed — images are pushed; create the tags manually if needed."
|
||||
|
||||
- name: Log out of Harbor
|
||||
if: always()
|
||||
run: docker logout "${HARBOR_HOST}" || true
|
||||
|
||||
@@ -74,7 +74,27 @@ Open <http://localhost:5173>. Sign up, then play. Saves sync to Postgres every 3
|
||||
| `pnpm format` / `pnpm format:check` | Prettier write / verify |
|
||||
| `pnpm --filter @teh-riehl/server prisma migrate dev` | Apply / regenerate the DB schema |
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs three jobs in parallel on every push: `static` (lint + typecheck + format + build), `test` (unit + integration against a Postgres service container), `security` (gitleaks + trivy).
|
||||
CI (`.github/workflows/ci.yml`) runs `test`, `lint`, `secrets-scan`, `vuln-scan`, and `sast` in parallel. On the `main` branch, it then builds + scans both Docker images (`tehriehlincremental-server`, `tehriehlincremental-client`), pushes signed (cosign) images to Harbor under `harbor.tehriehldeal.com/tehriehlincremental/{server,client}`, and back-pushes per-package git tags (`server-vX.Y.Z`, `client-vX.Y.Z`). Bump the version in `packages/{server,client}/package.json` before merging; the push job refuses to overwrite an existing Harbor tag.
|
||||
|
||||
## Container images
|
||||
|
||||
```bash
|
||||
# Build (from repo root — workspace deps need the full context)
|
||||
docker build -f packages/server/Dockerfile -t teh-riehl-server .
|
||||
docker build -f packages/client/Dockerfile -t teh-riehl-client .
|
||||
```
|
||||
|
||||
### Runtime config
|
||||
|
||||
The client is a static Vite build. Rather than baking the backend URL into the bundle at build time, the nginx entrypoint writes `/config.js` from env vars on container start, and `index.html` loads it before the React bundle. So one immutable image targets any backend:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 \
|
||||
-e APP_API_BASE_URL="https://api.example.com/api" \
|
||||
teh-riehl-client
|
||||
```
|
||||
|
||||
Add a new runtime knob by extending the `AppConfig` interface in `packages/client/src/lib/runtimeConfig.ts`, adding the env var to `packages/client/docker/40-app-config.sh`, and documenting it here.
|
||||
|
||||
## Security model
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.turbo
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,50 @@
|
||||
# =============================================================================
|
||||
# @teh-riehl/client — Vite + React static build, served by nginx.
|
||||
#
|
||||
# Built from the REPO ROOT so the workspace deps resolve:
|
||||
#
|
||||
# docker build -f packages/client/Dockerfile -t teh-riehl-client .
|
||||
#
|
||||
# Runtime config: the entrypoint script writes /usr/share/nginx/html/config.js
|
||||
# from environment variables before nginx starts. The app reads that file at
|
||||
# load time, so a SINGLE image can target any backend just by changing env.
|
||||
# =============================================================================
|
||||
|
||||
# ---- Builder ----------------------------------------------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
WORKDIR /repo
|
||||
|
||||
# Manifests first for layer caching.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/content/package.json packages/content/
|
||||
COPY packages/client/package.json packages/client/
|
||||
COPY packages/server/package.json packages/server/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Only the sources the client build needs.
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/content packages/content
|
||||
COPY packages/client packages/client
|
||||
|
||||
RUN pnpm --filter @teh-riehl/client build
|
||||
|
||||
# ---- Runtime ----------------------------------------------------------------
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# Static SPA + custom nginx config + the runtime-config entrypoint.
|
||||
COPY --from=builder /repo/packages/client/dist /usr/share/nginx/html
|
||||
COPY packages/client/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY packages/client/docker/40-app-config.sh /docker-entrypoint.d/40-app-config.sh
|
||||
|
||||
# nginx:alpine ships an entrypoint that runs scripts under /docker-entrypoint.d/
|
||||
# before starting; mark ours executable so it actually fires.
|
||||
RUN chmod +x /docker-entrypoint.d/40-app-config.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Inherit the upstream image's ENTRYPOINT + CMD. They run the *.sh scripts
|
||||
# in /docker-entrypoint.d/ (including ours) and then exec nginx.
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Runtime config materializer.
|
||||
#
|
||||
# Runs once at container startup (via nginx:alpine's /docker-entrypoint.d/
|
||||
# convention) and writes /usr/share/nginx/html/config.js from env vars BEFORE
|
||||
# nginx starts serving. The app's index.html loads /config.js before the
|
||||
# bundle, so window.__APP_CONFIG__ is populated by the time React mounts.
|
||||
#
|
||||
# Add new runtime knobs by:
|
||||
# 1. extending the AppConfig interface in src/lib/runtimeConfig.ts
|
||||
# 2. extending the JSON body below
|
||||
# 3. documenting the env var in the README
|
||||
# =============================================================================
|
||||
set -eu
|
||||
|
||||
TARGET="/usr/share/nginx/html/config.js"
|
||||
|
||||
# Defaults match the dev-mode public/config.js so behavior is consistent if
|
||||
# nothing is overridden (useful when running the image with no env vars).
|
||||
APP_API_BASE_URL="${APP_API_BASE_URL:-/api}"
|
||||
|
||||
cat > "$TARGET" <<EOF
|
||||
window.__APP_CONFIG__ = {
|
||||
apiBaseUrl: "$APP_API_BASE_URL"
|
||||
};
|
||||
EOF
|
||||
|
||||
echo "[entrypoint] $TARGET <- apiBaseUrl=$APP_API_BASE_URL"
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers. CSP intentionally permissive for fonts.googleapis;
|
||||
# tighten when self-hosting the Geist/JetBrains Mono webfonts.
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Hashed asset bundles — long cache, immutable.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Runtime config is REWRITTEN at every container start. No browser cache
|
||||
# or we'd ship stale apiBaseUrl after a redeploy.
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback — any other path resolves to the index so React Router
|
||||
# owns routing.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
</head>
|
||||
<body class="bg-bg text-fg antialiased scanlines">
|
||||
<div id="root"></div>
|
||||
<!-- Runtime config — MUST load before the app bundle so window.__APP_CONFIG__
|
||||
is populated by the time React mounts. In prod, the nginx container
|
||||
entrypoint rewrites this file from env vars at startup. -->
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Runtime configuration for local development.
|
||||
// In production, the container's entrypoint OVERWRITES this file from
|
||||
// env vars before nginx starts serving — never edit values here expecting
|
||||
// them to reach prod.
|
||||
window.__APP_CONFIG__ = {
|
||||
// Dev: Vite proxies /api/* to http://localhost:3000 (see vite.config.ts).
|
||||
apiBaseUrl: '/api',
|
||||
};
|
||||
@@ -3,8 +3,14 @@
|
||||
*
|
||||
* Tokens live in localStorage. A single in-flight refresh promise is
|
||||
* shared so concurrent 401s don't fire N parallel refresh calls.
|
||||
*
|
||||
* URLs are resolved through runtimeConfig.apiUrl() so the same bundle
|
||||
* works in dev (proxy to /api → localhost:3000) and prod (whatever
|
||||
* window.__APP_CONFIG__.apiBaseUrl was set to at container startup).
|
||||
*/
|
||||
|
||||
import { apiUrl } from './runtimeConfig';
|
||||
|
||||
const ACCESS_KEY = 'trk.access';
|
||||
const REFRESH_KEY = 'trk.refresh';
|
||||
|
||||
@@ -46,7 +52,7 @@ export async function api<T = unknown>(
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(path.startsWith('http') ? path : `/api${path}`, { ...init, headers });
|
||||
const res = await fetch(apiUrl(path), { ...init, headers });
|
||||
|
||||
if (res.status === 401 && retryOn401 && !skipAuth) {
|
||||
const newAccess = await refreshOnce();
|
||||
@@ -76,7 +82,7 @@ async function refreshOnce(): Promise<string | null> {
|
||||
if (!refresh) return null;
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
const res = await fetch(apiUrl('/auth/refresh'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: refresh }),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Runtime configuration.
|
||||
*
|
||||
* Vite bakes `import.meta.env.*` into the bundle at BUILD time, which means
|
||||
* one image per deploy target — unacceptable for an immutable-artifact
|
||||
* pipeline. Instead, the production container ships a single static build
|
||||
* and writes `/config.js` from environment variables at startup. The page
|
||||
* loads that script BEFORE the app bundle, so by the time React mounts,
|
||||
* `window.__APP_CONFIG__` is populated.
|
||||
*
|
||||
* For local dev, a static `public/config.js` provides sensible defaults so
|
||||
* the same code path works without any extra setup.
|
||||
*
|
||||
* In test environments (jsdom) `window` exists but `__APP_CONFIG__` may
|
||||
* not — the helper falls back to defaults gracefully.
|
||||
*/
|
||||
|
||||
export interface AppConfig {
|
||||
/**
|
||||
* Base URL the client uses to reach the NestJS API. Must NOT include a
|
||||
* trailing slash. Examples:
|
||||
* - "/api" (dev, proxied by Vite)
|
||||
* - "https://api.tehriehl.dev/api" (prod, same-origin or CORS)
|
||||
*/
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppConfig = {
|
||||
apiBaseUrl: '/api',
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: Partial<AppConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): AppConfig {
|
||||
if (typeof window === 'undefined') return DEFAULTS;
|
||||
const overrides = window.__APP_CONFIG__ ?? {};
|
||||
return { ...DEFAULTS, ...overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an API path (e.g. "/saves/me") to an absolute URL using the
|
||||
* current runtime config. Strips any trailing slash from the base so callers
|
||||
* don't need to worry about it.
|
||||
*/
|
||||
export function apiUrl(path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
const base = getConfig().apiBaseUrl.replace(/\/+$/, '');
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.turbo
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,79 @@
|
||||
# =============================================================================
|
||||
# @teh-riehl/server — NestJS + Prisma + envelope encryption.
|
||||
#
|
||||
# Built from the REPO ROOT (so the build context includes the workspace) with:
|
||||
#
|
||||
# docker build -f packages/server/Dockerfile -t teh-riehl-server .
|
||||
#
|
||||
# Multi-stage: a `builder` stage runs the full pnpm install / prisma generate
|
||||
# / nest build / pnpm deploy, then the runtime stage copies only the deployed
|
||||
# bundle (flat node_modules, no workspace links).
|
||||
# =============================================================================
|
||||
|
||||
# ---- Builder ----------------------------------------------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Build tools needed by argon2's native bindings.
|
||||
RUN apk add --no-cache python3 make g++ openssl
|
||||
|
||||
WORKDIR /repo
|
||||
|
||||
# Cache deps: copy manifests + lockfile first so node_modules can be reused
|
||||
# across source-only changes.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/content/package.json packages/content/
|
||||
COPY packages/client/package.json packages/client/
|
||||
COPY packages/server/package.json packages/server/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy the sources actually needed by the server build.
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/content packages/content
|
||||
COPY packages/server packages/server
|
||||
|
||||
# Generate Prisma client + compile the Nest build.
|
||||
# `exec prisma` (not bare `prisma`) so pnpm runs the CLI rather than looking
|
||||
# for an npm script of that name.
|
||||
RUN pnpm --filter @teh-riehl/server exec prisma generate
|
||||
RUN pnpm --filter @teh-riehl/server build
|
||||
|
||||
# `pnpm deploy --prod` flattens workspace deps into a self-contained tree.
|
||||
# Output ends up at /deploy with a normal node_modules.
|
||||
RUN pnpm deploy --filter=@teh-riehl/server --prod /deploy
|
||||
|
||||
# `pnpm deploy` ships a stub @prisma/client (the fallback default.js that
|
||||
# throws "did not initialize yet"). The real generated artifacts live in the
|
||||
# builder's pnpm store and aren't tracked as a declared dep, so they get
|
||||
# dropped. Replace the stub with the real generated client.
|
||||
RUN set -eux; \
|
||||
src="$(find /repo/node_modules/.pnpm -maxdepth 1 -type d -name '@prisma+client@*' | head -1)/node_modules/.prisma/client"; \
|
||||
dst="$(find /deploy/node_modules/.pnpm -maxdepth 1 -type d -name '@prisma+client@*' | head -1)/node_modules/.prisma/client"; \
|
||||
rm -rf "$dst"; \
|
||||
mkdir -p "$(dirname "$dst")"; \
|
||||
cp -r "$src" "$dst"
|
||||
|
||||
# ---- Runtime ----------------------------------------------------------------
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN apk add --no-cache openssl tini
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --from=builder /deploy/dist ./dist
|
||||
COPY --from=builder /deploy/prisma ./prisma
|
||||
COPY --from=builder /deploy/package.json ./package.json
|
||||
|
||||
# Drop privileges. The `node` user ships with the upstream image.
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
# tini = PID 1 with proper signal forwarding so SIGTERM cleans up the Node
|
||||
# process (Prisma keeps DB connections open otherwise).
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user