Files
TehRiehlBudget/.gitea/workflows/ci.yml
T
TehRiehlDeal 573c1ec7df Sign pushed images with cosign
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>
2026-05-06 16:42:07 -07:00

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