ci: add Harbor publish pipeline with supply-chain gates

GitHub Actions workflow: mix precommit gate against a postgres:17 service
container on PRs and pushes; on pushes to main, build the release image,
gate on a Trivy HIGH/CRITICAL scan, emit an SBOM, push to Harbor, and sign
the pushed tags with Cosign.

- Image: harbor.icecoldchris.dev/bulwark/bulwark (tags: sha-<sha>, latest)
- Commit the Cosign public key (cosign.pub) for verification; gitignore the
  private key (cosign.key / *.key)
This commit is contained in:
Christopher Fahlin
2026-05-30 19:27:05 -07:00
parent ea814c5125
commit 2f5587aaec
3 changed files with 209 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# Match the Dockerfile builder (hexpm/elixir image) so CI and the release
# image compile against the same toolchain.
ELIXIR_VERSION: '1.18.4'
OTP_VERSION: '27.3.4'
MIX_ENV: test
jobs:
test:
name: mix precommit
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: bulwark_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
# The suite reads TEST_DATABASE_URL (hermetic — see config/test.exs); the
# cache stays disabled (no TEST_VALKEY_URL) so no Valkey service is needed.
TEST_DATABASE_URL: ecto://postgres:postgres@localhost:5432/bulwark_test
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Elixir/OTP
uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- name: Cache deps and _build
uses: actions/cache@v4
with:
path: |
deps
_build
key: mix-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock') }}
restore-keys: |
mix-${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-
- name: Install dependencies
run: mix deps.get
# mix precommit = compile --warnings-as-errors, deps.unlock --unused,
# format --check-formatted, test. Matches the local gate in CLAUDE.md.
- name: Run precommit gate
run: mix precommit
# Single required status check for branch protection. Point the repo's
# branch-protection rule at "CI / All checks passed" so the set of jobs
# can change without touching Settings.
ci-ok:
name: All checks passed
if: always()
needs: [test]
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Evaluate job results
run: |
results=("${{ needs.test.result }}")
for r in "${results[@]}"; do
if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then
echo "::error::Upstream job result: $r"
exit 1
fi
done
publish:
name: Build and push to Harbor
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'push'
needs: [test]
permissions:
contents: read
env:
REGISTRY: harbor.icecoldchris.dev
IMAGE: harbor.icecoldchris.dev/bulwark/bulwark
SCAN_TAG: harbor.icecoldchris.dev/bulwark/bulwark:sha-${{ github.sha }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}
# Build into the local daemon first. Nothing reaches Harbor until the
# supply-chain gates below pass. The release image is amd64-target; CI
# runners are amd64, so no cross-arch build is needed here.
- id: build
uses: docker/build-push-action@v6
with:
context: .
load: true
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
provenance: false
- name: Trivy vulnerability scan (gating)
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: ${{ env.SCAN_TAG }}
format: table
severity: HIGH,CRITICAL
ignore-unfixed: true
vuln-type: os,library
exit-code: '1'
- name: Trivy SARIF report
if: failure()
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: ${{ env.SCAN_TAG }}
format: sarif
output: trivy.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
vuln-type: os,library
exit-code: '0'
skip-setup-trivy: true
- name: Upload Trivy SARIF artifact
if: failure() && hashFiles('trivy.sarif') != ''
uses: actions/upload-artifact@v4
with:
name: trivy-sarif
path: trivy.sarif
retention-days: 30
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ${{ env.SCAN_TAG }}
format: spdx-json
output-file: sbom.spdx.json
upload-artifact: true
upload-artifact-retention: 30
- name: Push image to Harbor
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
- name: Install Cosign
uses: sigstore/cosign-installer@v3.8.1
- name: Sign the published images
run: |
echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign --yes --key env://COSIGN_PRIVATE_KEY {}
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
+4
View File
@@ -35,6 +35,10 @@ npm-debug.log
!.env.example
!.env.local.example
# Cosign signing key (private). The .pub is safe to commit (used to verify).
cosign.key
*.key
# Editor / IDE
.vscode/
.idea/
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESbpIb0jeYwsdTr1qHnI/JVH92JQe
b6kAlL5RB/ggG7sJIqdYw6gX9xFVxUQ3ALq1oO6m7wtE+LMvAJd2yOhKZA==
-----END PUBLIC KEY-----