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:
@@ -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 }}
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESbpIb0jeYwsdTr1qHnI/JVH92JQe
|
||||
b6kAlL5RB/ggG7sJIqdYw6gX9xFVxUQ3ALq1oO6m7wtE+LMvAJd2yOhKZA==
|
||||
-----END PUBLIC KEY-----
|
||||
Reference in New Issue
Block a user