mirror of
https://github.com/Lissy93/dashy.git
synced 2026-06-02 06:44:51 -04:00
401 lines
13 KiB
YAML
401 lines
13 KiB
YAML
# Builds and publishes the multi-arch Docker image
|
|
#
|
|
# Triggered by:
|
|
# - On git tag push, publishes to :X.Y.Z, :X.Y, and :latest
|
|
# - On manual dispatch from master, rebuilds and updates :latest
|
|
# - On weekly cron, rebuilds :latest from master for upstream patches
|
|
#
|
|
# The workflow will:
|
|
# - Builds multi-arch (amd64, arm64, armv7) in parallel on native runners
|
|
# - Trivy scans + reports security issues, and fails on CRITICAL CVEs
|
|
# - Publishes to GHCR, and to Docker Hub if creds are configured
|
|
# - Attests both the build provenance and SBOM and publishes to GHCR
|
|
# - Uploads digest, SBOM and outputs as artifact, and shows MD summary
|
|
|
|
name: 🐳 Docker
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: 'Existing git tag to build. Empty = build current ref as :latest.'
|
|
required: false
|
|
default: ''
|
|
push:
|
|
tags: ['*.*.*']
|
|
schedule:
|
|
- cron: '0 4 * * 0'
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.tag }}
|
|
cancel-in-progress: false
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
DH_IMAGE: ${{ vars.DOCKER_REPO || 'lissy93/dashy' }}
|
|
GH_IMAGE: ghcr.io/${{ github.repository }}
|
|
|
|
jobs:
|
|
build:
|
|
name: 🔨 Build (${{ matrix.arch }})
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
security-events: write
|
|
env:
|
|
DOCKER_BUILD_SUMMARY: "false"
|
|
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- platform: linux/amd64
|
|
runner: ubuntu-latest
|
|
arch: amd64
|
|
- platform: linux/arm64
|
|
runner: ubuntu-24.04-arm
|
|
arch: arm64
|
|
- platform: linux/arm/v7
|
|
runner: ubuntu-latest
|
|
arch: armv7
|
|
runs-on: ${{ matrix.runner }}
|
|
steps:
|
|
- name: 🛎️ Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ inputs.tag || github.ref }}
|
|
|
|
- name: 🏷️ Resolve build version
|
|
id: version
|
|
env:
|
|
INPUT_TAG: ${{ inputs.tag }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
REF_NAME: ${{ github.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -n "$INPUT_TAG" ]; then
|
|
v="$INPUT_TAG"
|
|
elif [ "$EVENT_NAME" = "push" ]; then
|
|
v="$REF_NAME"
|
|
else
|
|
v="latest"
|
|
fi
|
|
echo "value=$v" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: 🔧 Set up QEMU
|
|
if: matrix.arch == 'armv7'
|
|
uses: docker/setup-qemu-action@v4
|
|
with:
|
|
platforms: linux/arm/v7
|
|
|
|
- name: 🔧 Set up Buildx
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
- name: 🔑 Login to GHCR
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: ⏱️ Capture build timestamp
|
|
id: timestamp
|
|
run: echo "iso=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: 🔨 Build image (load for scan)
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
platforms: ${{ matrix.platform }}
|
|
cache-from: type=gha,scope=${{ matrix.arch }}
|
|
cache-to: type=gha,scope=${{ matrix.arch }},mode=max
|
|
load: true
|
|
tags: dashy-scan:${{ matrix.arch }}
|
|
provenance: false
|
|
build-args: |
|
|
VERSION=${{ steps.version.outputs.value }}
|
|
REVISION=${{ github.sha }}
|
|
CREATED=${{ steps.timestamp.outputs.iso }}
|
|
|
|
- name: 🛡️ Trivy vulnerability scan
|
|
uses: aquasecurity/trivy-action@v0.36.0
|
|
env:
|
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
|
TRIVY_JAVA_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-java-db:1
|
|
with:
|
|
version: v0.70.0
|
|
image-ref: dashy-scan:${{ matrix.arch }}
|
|
severity: CRITICAL
|
|
ignore-unfixed: true
|
|
exit-code: ${{ github.event_name == 'schedule' && '1' || '0' }}
|
|
vuln-type: 'os,library'
|
|
format: 'sarif'
|
|
output: 'trivy-${{ matrix.arch }}.sarif'
|
|
timeout: '10m'
|
|
|
|
- name: 📤 Upload Trivy SARIF
|
|
if: always() && hashFiles(format('trivy-{0}.sarif', matrix.arch)) != ''
|
|
uses: github/codeql-action/upload-sarif@v4
|
|
with:
|
|
sarif_file: trivy-${{ matrix.arch }}.sarif
|
|
category: trivy-${{ matrix.arch }}
|
|
|
|
- name: 📤 Upload Trivy artifact
|
|
if: always() && hashFiles(format('trivy-{0}.sarif', matrix.arch)) != ''
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: trivy-${{ matrix.arch }}
|
|
path: trivy-${{ matrix.arch }}.sarif
|
|
if-no-files-found: ignore
|
|
retention-days: 1
|
|
|
|
- name: 🚀 Push by digest
|
|
id: push
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
platforms: ${{ matrix.platform }}
|
|
cache-from: type=gha,scope=${{ matrix.arch }}
|
|
outputs: type=image,name=${{ env.GH_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
|
provenance: false
|
|
build-args: |
|
|
VERSION=${{ steps.version.outputs.value }}
|
|
REVISION=${{ github.sha }}
|
|
CREATED=${{ steps.timestamp.outputs.iso }}
|
|
|
|
- name: 🧬 Write digest
|
|
env:
|
|
DIGEST: ${{ steps.push.outputs.digest }}
|
|
DIGESTS_DIR: ${{ runner.temp }}/digests
|
|
ARCH: ${{ matrix.arch }}
|
|
run: |
|
|
mkdir -p "$DIGESTS_DIR"
|
|
echo "$DIGEST" > "$DIGESTS_DIR/$ARCH"
|
|
|
|
- name: 📤 Upload digest
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: digest-${{ matrix.arch }}
|
|
path: ${{ runner.temp }}/digests/${{ matrix.arch }}
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
merge:
|
|
name: 🧩 Merge & Push Manifests
|
|
needs: build
|
|
timeout-minutes: 30
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write
|
|
attestations: write
|
|
env:
|
|
HAS_DH: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}
|
|
SEMVER_VALUE: ${{ inputs.tag || github.ref_name }}
|
|
SEMVER_ENABLE: ${{ github.event_name == 'push' || inputs.tag != '' }}
|
|
LATEST_ENABLE: ${{ inputs.tag == '' }}
|
|
steps:
|
|
- name: 📥 Download digests
|
|
uses: actions/download-artifact@v7
|
|
with:
|
|
path: ${{ runner.temp }}/digests
|
|
pattern: digest-*
|
|
merge-multiple: true
|
|
|
|
- name: 📥 Download Trivy SARIFs
|
|
uses: actions/download-artifact@v7
|
|
continue-on-error: true
|
|
with:
|
|
path: ${{ runner.temp }}/trivy
|
|
pattern: trivy-*
|
|
merge-multiple: true
|
|
|
|
- name: 🔧 Set up Buildx
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
- name: 🔑 Login to GHCR
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: 🔑 Login to Docker Hub
|
|
if: env.HAS_DH == 'true'
|
|
uses: docker/login-action@v4
|
|
with:
|
|
username: ${{ secrets.DOCKER_USERNAME }}
|
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
|
|
- name: 🗂️ Generate tags
|
|
id: meta
|
|
uses: docker/metadata-action@v6
|
|
with:
|
|
images: |
|
|
${{ env.GH_IMAGE }}
|
|
${{ env.HAS_DH == 'true' && env.DH_IMAGE || '' }}
|
|
tags: |
|
|
type=raw,value=latest,enable=${{ env.LATEST_ENABLE }}
|
|
type=semver,pattern={{version}},value=${{ env.SEMVER_VALUE }},enable=${{ env.SEMVER_ENABLE }}
|
|
type=semver,pattern={{major}}.{{minor}},value=${{ env.SEMVER_VALUE }},enable=${{ env.SEMVER_ENABLE }}
|
|
type=semver,pattern={{major}}.x,value=${{ env.SEMVER_VALUE }},enable=${{ env.SEMVER_ENABLE }}
|
|
flavor: |
|
|
latest=false
|
|
|
|
- name: 🧩 Create & push manifest
|
|
id: manifest
|
|
working-directory: ${{ runner.temp }}/digests
|
|
run: |
|
|
set -euo pipefail
|
|
TAGS=()
|
|
while IFS= read -r tag; do TAGS+=(-t "$tag"); done \
|
|
< <(jq -r '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
|
SOURCES=()
|
|
for f in *; do SOURCES+=("${GH_IMAGE}@$(cat "$f")"); done
|
|
docker buildx imagetools create "${TAGS[@]}" "${SOURCES[@]}"
|
|
PRIMARY=$(jq -r --arg img "$GH_IMAGE" \
|
|
'[.tags[] | select(startswith($img + ":"))] | first // empty' \
|
|
<<< "$DOCKER_METADATA_OUTPUT_JSON")
|
|
DIGEST=$(docker buildx imagetools inspect "$PRIMARY" --format '{{.Manifest.Digest}}')
|
|
echo "primary_tag=$PRIMARY" >> "$GITHUB_OUTPUT"
|
|
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: 🔐 Generate SBOM (SPDX)
|
|
uses: anchore/sbom-action@v0.24.0
|
|
with:
|
|
image: ${{ steps.manifest.outputs.primary_tag }}
|
|
format: spdx-json
|
|
output-file: sbom.spdx.json
|
|
upload-artifact: false
|
|
|
|
- name: 🪪 Attest SBOM
|
|
id: attest_sbom
|
|
uses: actions/attest@v4
|
|
continue-on-error: true
|
|
with:
|
|
subject-name: ${{ env.GH_IMAGE }}
|
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
|
sbom-path: sbom.spdx.json
|
|
push-to-registry: true
|
|
|
|
- name: 🛡️ Attest build provenance
|
|
id: attest_provenance
|
|
uses: actions/attest-build-provenance@v4
|
|
continue-on-error: true
|
|
with:
|
|
subject-name: ${{ env.GH_IMAGE }}
|
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
|
push-to-registry: true
|
|
|
|
- name: 📋 Summary
|
|
if: always()
|
|
continue-on-error: true
|
|
env:
|
|
SBOM_OUTCOME: ${{ steps.attest_sbom.outcome }}
|
|
SBOM_URL: ${{ steps.attest_sbom.outputs.attestation-url }}
|
|
PROV_OUTCOME: ${{ steps.attest_provenance.outcome }}
|
|
PROV_URL: ${{ steps.attest_provenance.outputs.attestation-url }}
|
|
DIGEST: ${{ steps.manifest.outputs.digest }}
|
|
PRIMARY: ${{ steps.manifest.outputs.primary_tag }}
|
|
TAGS_JSON: ${{ steps.meta.outputs.json }}
|
|
DIGESTS_DIR: ${{ runner.temp }}/digests
|
|
TRIVY_DIR: ${{ runner.temp }}/trivy
|
|
# Behold, some ugly bash, to produce a pretty output (don't read it, just trust)
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
attest_line() {
|
|
local label="$1" outcome="$2" url="$3"
|
|
case "$outcome" in
|
|
success)
|
|
if [ -n "$url" ]; then
|
|
echo "- ✅ $label attested ([view]($url))"
|
|
else
|
|
echo "- ✅ $label attested"
|
|
fi ;;
|
|
failure) echo "- ⚠️ $label attestation failed (image pushed without attest)" ;;
|
|
*) echo "- ⏭️ $label attestation \`$outcome\`" ;;
|
|
esac
|
|
}
|
|
|
|
trivy_section() {
|
|
local dir="$1"
|
|
[ -d "$dir" ] || return 0
|
|
local found=0
|
|
local lines=""
|
|
local arch f n
|
|
for arch in amd64 arm64 armv7; do
|
|
f="$dir/trivy-$arch.sarif"
|
|
[ -f "$f" ] || continue
|
|
found=1
|
|
n=$(jq '[.runs[]?.results[]?] | length' "$f" 2>/dev/null || echo 0)
|
|
[[ "$n" =~ ^[0-9]+$ ]] || n=0
|
|
if [ "$n" = "0" ]; then
|
|
lines+="- ✅ \`$arch\` — no fixable CRITICAL CVEs"$'\n'
|
|
else
|
|
lines+="- ⚠️ \`$arch\` — $n fixable CRITICAL CVE(s)"$'\n'
|
|
fi
|
|
done
|
|
[ "$found" = "1" ] || return 0
|
|
echo "## Security Scan"
|
|
echo ""
|
|
echo "Trivy (CRITICAL severity, fixable only):"
|
|
echo ""
|
|
printf '%s\n' "$lines"
|
|
echo "---"
|
|
echo ""
|
|
}
|
|
|
|
arch_section() {
|
|
local arch="$1"
|
|
local file="$DIGESTS_DIR/$arch"
|
|
[ -f "$file" ] || return 0
|
|
local digest manifest size count
|
|
digest=$(cat "$file")
|
|
manifest=$(docker buildx imagetools inspect "${PRIMARY%%:*}@$digest" --raw 2>/dev/null || echo '{}')
|
|
size=$(jq '[.layers[]?.size // 0] | add // 0' <<< "$manifest")
|
|
count=$(jq '.layers // [] | length' <<< "$manifest")
|
|
echo "#### Dashy \`$arch\`"
|
|
echo ""
|
|
echo "- **Digest:** \`$digest\`"
|
|
[ "$size" != "0" ] && echo "- **Size:** $(numfmt --to=iec --suffix=B "$size" 2>/dev/null || echo "$size B")"
|
|
[ "$count" != "0" ] && echo "- **Layers:** $count"
|
|
echo ""
|
|
}
|
|
|
|
# Clear auto-generated "Attestation Created" blocks from attest actions.
|
|
: > "$GITHUB_STEP_SUMMARY"
|
|
{
|
|
if [ -n "$DIGEST" ]; then
|
|
echo "## Docker Image"
|
|
echo ""
|
|
echo "**Manifest:** \`$DIGEST\`"
|
|
echo ""
|
|
echo '```bash'
|
|
jq -r '.tags[] | "docker pull \(.)"' <<< "$TAGS_JSON"
|
|
echo '```'
|
|
echo ""
|
|
echo "---"
|
|
echo ""
|
|
fi
|
|
echo "## Attestations"
|
|
echo ""
|
|
attest_line "SBOM" "$SBOM_OUTCOME" "$SBOM_URL"
|
|
attest_line "Build provenance" "$PROV_OUTCOME" "$PROV_URL"
|
|
echo ""
|
|
echo "---"
|
|
echo ""
|
|
trivy_section "$TRIVY_DIR"
|
|
echo "## Build Info"
|
|
echo ""
|
|
for arch in amd64 arm64 armv7; do
|
|
arch_section "$arch"
|
|
done
|
|
} >> "$GITHUB_STEP_SUMMARY"
|