573c1ec7df
After the Harbor push, install cosign and sign each image's digest with a key stored in COSIGN_PRIVATE_KEY / COSIGN_PASSWORD secrets. Cosign resolves the SHA tag to the underlying digest, so a single signature covers every tag (version, sha, latest) pointing at the same image. Harbor looks up signatures by digest and will display "signed" status once the signature artifact lands alongside the image. Cosign is curl-installed at v2.4.1 and uses the existing docker login for registry auth — no extra credentials needed beyond the cosign key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
410 lines
14 KiB
YAML
410 lines
14 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
pull_request:
|
|
|
|
jobs:
|
|
test:
|
|
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: 20
|
|
cache: pnpm
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Generate Prisma client
|
|
run: pnpm --filter tehriehlbudget-backend exec prisma generate
|
|
|
|
- name: Backend tests (Jest)
|
|
run: pnpm test:backend
|
|
|
|
- name: Frontend tests (Vitest)
|
|
# Supabase's createClient validates the URL at module load, so the
|
|
# frontend tests need *some* value to import lib/supabase without
|
|
# throwing. Real auth calls are stubbed in the tests, so these are
|
|
# purely structural placeholders — never used over the wire.
|
|
env:
|
|
VITE_SUPABASE_URL: http://localhost:54321
|
|
VITE_SUPABASE_ANON_KEY: placeholder-anon-key-for-tests-only
|
|
run: pnpm test:frontend
|
|
|
|
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: 20
|
|
cache: pnpm
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Generate Prisma client
|
|
# Backend ESLint imports types from the generated Prisma client.
|
|
run: pnpm --filter tehriehlbudget-backend exec prisma generate
|
|
|
|
- name: Lint
|
|
run: pnpm lint
|
|
|
|
- name: Format check
|
|
run: pnpm format:check
|
|
|
|
secrets-scan:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- 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: Run gitleaks
|
|
# TODO: flip to --exit-code 1 once the baseline is clean
|
|
run: |
|
|
gitleaks detect \
|
|
--source . \
|
|
--config .gitleaks.toml \
|
|
--report-format sarif \
|
|
--report-path gitleaks.sarif \
|
|
--redact \
|
|
--no-git \
|
|
--exit-code 0
|
|
|
|
- uses: actions/upload-artifact@v3
|
|
if: always()
|
|
with:
|
|
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)
|
|
BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json)
|
|
FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json)
|
|
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
|
echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV"
|
|
echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV"
|
|
|
|
- name: Build backend image
|
|
run: |
|
|
docker buildx build --load \
|
|
-f tehriehlbudget-backend/Dockerfile \
|
|
-t tehriehlbudget-backend:${SHA_SHORT} \
|
|
.
|
|
|
|
- name: Build frontend image
|
|
run: |
|
|
docker buildx build --load \
|
|
-f tehriehlbudget-frontend/Dockerfile \
|
|
-t tehriehlbudget-frontend:${SHA_SHORT} \
|
|
.
|
|
|
|
- name: Save images for downstream jobs
|
|
run: |
|
|
docker save \
|
|
tehriehlbudget-backend:${SHA_SHORT} \
|
|
tehriehlbudget-frontend:${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 — backend (HIGH/CRITICAL gate)
|
|
run: |
|
|
# Print the finding table to the job log first so the cause of any
|
|
# failure is visible inline (the SARIF run below writes only to file).
|
|
# The DB downloaded here is cached for the SARIF run that follows.
|
|
docker run --rm \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
aquasec/trivy:latest image \
|
|
--severity HIGH,CRITICAL \
|
|
--ignore-unfixed \
|
|
--format table \
|
|
tehriehlbudget-backend:${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-backend.sarif \
|
|
tehriehlbudget-backend:${SHA_SHORT}
|
|
|
|
- name: Trivy image scan — frontend (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 \
|
|
tehriehlbudget-frontend:${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-frontend.sarif \
|
|
tehriehlbudget-frontend:${SHA_SHORT}
|
|
|
|
- name: Trivy SBOM — backend
|
|
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-backend.cdx.json \
|
|
tehriehlbudget-backend:${SHA_SHORT}
|
|
|
|
- name: Trivy SBOM — frontend
|
|
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-frontend.cdx.json \
|
|
tehriehlbudget-frontend:${SHA_SHORT}
|
|
|
|
- uses: actions/upload-artifact@v3
|
|
if: always()
|
|
with:
|
|
name: trivy-image-reports
|
|
# `warn` so the upload doesn't fail the run if a SARIF/SBOM is
|
|
# missing because an earlier scan step short-circuited.
|
|
if-no-files-found: warn
|
|
path: |
|
|
trivy-image-backend.sarif
|
|
trivy-image-frontend.sarif
|
|
sbom-backend.cdx.json
|
|
sbom-frontend.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: tehriehlbudget
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Need full history so the back-pushed git tag can be created against
|
|
# the right commit, and so token-auth on push to origin works.
|
|
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)
|
|
BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json)
|
|
FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json)
|
|
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
|
echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV"
|
|
echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV"
|
|
|
|
- name: Refuse to overwrite an existing version tag
|
|
env:
|
|
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
|
|
HARBOR_PASSWORD: ${{ secrets.HARBOR_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 backend "${BACKEND_VERSION}"
|
|
check_tag frontend "${FRONTEND_VERSION}"
|
|
|
|
- name: Log in to Harbor
|
|
env:
|
|
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
|
|
HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
|
|
run: |
|
|
echo "${HARBOR_PASSWORD}" | docker login "${HARBOR_HOST}" -u "${HARBOR_USERNAME}" --password-stdin
|
|
|
|
- name: Tag and push backend image
|
|
run: |
|
|
REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/backend"
|
|
docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:${BACKEND_VERSION}
|
|
docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:${SHA_SHORT}
|
|
docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:latest
|
|
docker push ${REPO}:${BACKEND_VERSION}
|
|
docker push ${REPO}:${SHA_SHORT}
|
|
docker push ${REPO}:latest
|
|
|
|
- name: Tag and push frontend image
|
|
run: |
|
|
REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/frontend"
|
|
docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:${FRONTEND_VERSION}
|
|
docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:${SHA_SHORT}
|
|
docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:latest
|
|
docker push ${REPO}:${FRONTEND_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 a
|
|
# single signature covers every tag pointing at the same digest
|
|
# (version, sha, latest). Harbor looks up signatures by digest.
|
|
# Reuses the docker login from earlier in this job for registry auth.
|
|
cosign sign --key env://COSIGN_KEY --yes \
|
|
"${HARBOR_HOST}/${HARBOR_PROJECT}/backend:${SHA_SHORT}"
|
|
cosign sign --key env://COSIGN_KEY --yes \
|
|
"${HARBOR_HOST}/${HARBOR_PROJECT}/frontend:${SHA_SHORT}"
|
|
|
|
- name: Push back per-package git tags
|
|
env:
|
|
# Auto-injected per-run token; has push permission to this repo.
|
|
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 "backend-v${BACKEND_VERSION}" -m "backend ${BACKEND_VERSION}" 2>/dev/null || true
|
|
git tag "frontend-v${FRONTEND_VERSION}" -m "frontend ${FRONTEND_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
|