mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
The advisory Trivy image scan uploaded every HIGH/CRITICAL into the Security tab without ignore-unfixed, flooding it with ~150 unfixable base-image OS CVEs (Debian trixie packages with no upstream patch, overwhelmingly local-only and not reachable from the proxy request surface). Operators cannot act on those, so they are pure noise. Add ignore-unfixed:true to the advisory step so it mirrors the existing CRITICAL blocking gate and surfaces only actionable, fixable vulnerabilities. Wire trivyignores to a new repo-root .trivyignore that documents the accepted-risk policy and is the single auditable home for the rare fixable CVE we must temporarily accept (none at present). Takes effect on the next release image build (Trivy only runs on tag builds, not main pushes); fixed CVEs drop out of the SARIF and GitHub auto-resolves the corresponding alerts.
407 lines
15 KiB
YAML
407 lines
15 KiB
YAML
name: Publish to Docker Hub
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
tags:
|
|
- "v*"
|
|
paths-ignore:
|
|
- ".github/workflows/**"
|
|
# Use 'released' instead of 'published' so editing/re-publishing old releases
|
|
# does NOT re-trigger this workflow. 'released' fires only on the initial
|
|
# release publication (and pre-release → release transition).
|
|
release:
|
|
types: [released]
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: "Version tag to build (e.g. 3.8.4)"
|
|
required: true
|
|
type: string
|
|
promote_latest:
|
|
description: "Also tag :latest (only if this is the highest semver)"
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
|
|
# Least-privilege default: read-only at the top level; the build and merge jobs that
|
|
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
prepare:
|
|
name: Resolve Docker release metadata
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
version: ${{ steps.version.outputs.version }}
|
|
promote_latest: ${{ steps.version.outputs.promote_latest }}
|
|
skip: ${{ steps.version.outputs.skip }}
|
|
env:
|
|
IMAGE_NAME: diegosouzapw/omniroute
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
|
# Need full tag history for semver comparison when deciding :latest.
|
|
fetch-depth: 0
|
|
|
|
- name: Resolve version, latest-promotion, and skip flag
|
|
id: version
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
REF_NAME: ${{ github.ref_name }}
|
|
REF_TYPE: ${{ github.ref_type }}
|
|
INPUT_VERSION: ${{ inputs.version }}
|
|
PROMOTE_INPUT: ${{ inputs.promote_latest }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# 1) Resolve version string from the trigger (all inputs come via env).
|
|
case "$EVENT_NAME" in
|
|
workflow_dispatch)
|
|
VERSION="${INPUT_VERSION#v}"
|
|
;;
|
|
push)
|
|
if [ "$REF_TYPE" = "tag" ]; then
|
|
VERSION="${REF_NAME#v}"
|
|
else
|
|
# Push to main → build & tag as `main` only. Never touch :latest.
|
|
VERSION="main"
|
|
fi
|
|
;;
|
|
release)
|
|
VERSION="${REF_NAME#v}"
|
|
;;
|
|
*)
|
|
VERSION="${REF_NAME#v}"
|
|
;;
|
|
esac
|
|
# Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth).
|
|
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
|
|
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
|
|
exit 1
|
|
fi
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
|
|
# 2) Decide whether to promote :latest.
|
|
PROMOTE="false"
|
|
if [ "$VERSION" = "main" ]; then
|
|
PROMOTE="false"
|
|
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
|
|
echo "Pre-release identifier detected — skipping :latest."
|
|
PROMOTE="false"
|
|
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
|
PROMOTE="${PROMOTE_INPUT:-false}"
|
|
else
|
|
git fetch --tags --quiet || true
|
|
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
|
|
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
|
|
PROMOTE="true"
|
|
else
|
|
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
|
|
fi
|
|
fi
|
|
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
|
|
|
# 3) Skip if this exact version is already published in Docker Hub.
|
|
# `main` is always rebuilt (mutable floating tag).
|
|
SKIP="false"
|
|
if [ "$VERSION" != "main" ]; then
|
|
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
|
|
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
|
|
SKIP="true"
|
|
fi
|
|
fi
|
|
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
|
|
|
|
echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)"
|
|
|
|
build:
|
|
name: Build Docker (${{ matrix.platform }})
|
|
needs: prepare
|
|
if: needs.prepare.outputs.skip != 'true'
|
|
runs-on: ${{ matrix.runner }}
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- platform: linux/amd64
|
|
runner: ubuntu-24.04
|
|
arch: amd64
|
|
- platform: linux/arm64
|
|
runner: ubuntu-24.04-arm
|
|
arch: arm64
|
|
env:
|
|
IMAGE_NAME: diegosouzapw/omniroute
|
|
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
- name: Login to Docker Hub
|
|
uses: docker/login-action@v4
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Login to GitHub Container Registry
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Build and push platform image by digest
|
|
id: build
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
target: runner-base
|
|
platforms: ${{ matrix.platform }}
|
|
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
|
tags: |
|
|
${{ env.IMAGE_NAME }}
|
|
${{ env.GHCR_IMAGE_NAME }}
|
|
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
|
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
|
|
no-cache: false
|
|
env:
|
|
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
|
|
|
- name: Build and push WEB platform image by digest
|
|
id: build-web
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
target: runner-web
|
|
platforms: ${{ matrix.platform }}
|
|
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
|
tags: |
|
|
${{ env.IMAGE_NAME }}
|
|
${{ env.GHCR_IMAGE_NAME }}
|
|
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
|
|
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
|
|
no-cache: false
|
|
env:
|
|
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
|
|
|
- name: Export digests
|
|
env:
|
|
DIGEST_BASE: ${{ steps.build.outputs.digest }}
|
|
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p /tmp/digests/base /tmp/digests/web
|
|
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
|
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
|
|
|
- name: Upload base digests
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: digests-base-${{ matrix.arch }}
|
|
path: /tmp/digests/base/*
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
- name: Upload web digests
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: digests-web-${{ matrix.arch }}
|
|
path: /tmp/digests/web/*
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
merge:
|
|
name: Publish multi-arch manifests
|
|
needs:
|
|
- prepare
|
|
- build
|
|
if: needs.prepare.outputs.skip != 'true'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
security-events: write
|
|
env:
|
|
IMAGE_NAME: diegosouzapw/omniroute
|
|
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
- name: Login to Docker Hub
|
|
uses: docker/login-action@v4
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Login to GitHub Container Registry
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Download base digests
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
pattern: digests-base-*
|
|
path: /tmp/digests/base
|
|
merge-multiple: true
|
|
|
|
- name: Download web digests
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
pattern: digests-web-*
|
|
path: /tmp/digests/web
|
|
merge-multiple: true
|
|
|
|
- name: Create Docker Hub manifest
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
create_manifest() {
|
|
local image="$1" suffix="$2" dir="$3"
|
|
local tags=(-t "${image}:${VERSION}${suffix}")
|
|
if [ "$PROMOTE_LATEST" = "true" ]; then
|
|
tags+=(-t "${image}:latest${suffix}")
|
|
fi
|
|
local refs=()
|
|
while IFS= read -r digest_file; do
|
|
refs+=("${image}@sha256:$(basename "$digest_file")")
|
|
done < <(find "$dir" -type f | sort)
|
|
if [ "${#refs[@]}" -eq 0 ]; then
|
|
echo "No image digests in $dir" >&2
|
|
exit 1
|
|
fi
|
|
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
|
}
|
|
|
|
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
|
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
|
|
|
- name: Create GHCR manifest
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
create_manifest() {
|
|
local image="$1" suffix="$2" dir="$3"
|
|
local tags=(-t "${image}:${VERSION}${suffix}")
|
|
if [ "$PROMOTE_LATEST" = "true" ]; then
|
|
tags+=(-t "${image}:latest${suffix}")
|
|
fi
|
|
local refs=()
|
|
while IFS= read -r digest_file; do
|
|
refs+=("${image}@sha256:$(basename "$digest_file")")
|
|
done < <(find "$dir" -type f | sort)
|
|
if [ "${#refs[@]}" -eq 0 ]; then
|
|
echo "No image digests in $dir" >&2
|
|
exit 1
|
|
fi
|
|
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
|
}
|
|
|
|
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
|
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
|
|
|
- name: Inspect image
|
|
if: needs.prepare.outputs.version != 'main'
|
|
run: |
|
|
docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}"
|
|
|
|
- name: Generate CycloneDX SBOM (image, advisory)
|
|
if: needs.prepare.outputs.version != 'main'
|
|
continue-on-error: true
|
|
uses: anchore/sbom-action@v0
|
|
with:
|
|
image: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
|
format: cyclonedx-json
|
|
output-file: sbom-image.cdx.json
|
|
artifact-name: sbom-image.cdx.json
|
|
|
|
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
|
|
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
|
|
#
|
|
# ignore-unfixed mirrors the blocking gate: the Security tab must surface only
|
|
# ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding
|
|
# on a patched base or bumping the dep. Without it the advisory upload floods the
|
|
# tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream
|
|
# patch yet, overwhelmingly local-only and not reachable from the proxy request
|
|
# surface), which is noise an operator cannot act on. trivyignores points at the
|
|
# repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home.
|
|
# See docs/security/SUPPLY_CHAIN.md.
|
|
- name: Trivy image scan (SARIF, advisory)
|
|
if: needs.prepare.outputs.version != 'main'
|
|
continue-on-error: true
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
|
format: sarif
|
|
output: trivy-results.sarif
|
|
severity: HIGH,CRITICAL
|
|
ignore-unfixed: true
|
|
trivyignores: .trivyignore
|
|
exit-code: "0"
|
|
|
|
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
|
|
# published image. Narrowed to severity CRITICAL (HIGH stays visible in the
|
|
# SARIF step above, not blocking). ignore-unfixed:true so an unfixable base-image
|
|
# CVE with no upstream patch does not red the release (reduces false-blocks);
|
|
# a fixable CRITICAL still blocks. Per docs/security/SUPPLY_CHAIN.md. NB: Trivy
|
|
# scans against a CVE DB that grows continuously — a newly-disclosed CRITICAL on
|
|
# an unchanged base image can red this gate; the fix is to rebuild on a patched
|
|
# base, bump the dep, or add a justified .trivyignore entry (see the CVE-variance
|
|
# note in docs/security/SUPPLY_CHAIN.md).
|
|
- name: Trivy CRITICAL gate (blocking)
|
|
if: needs.prepare.outputs.version != 'main'
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
|
format: table
|
|
severity: CRITICAL
|
|
ignore-unfixed: true
|
|
exit-code: "1"
|
|
|
|
- name: Upload Trivy SARIF to Security tab
|
|
if: needs.prepare.outputs.version != 'main'
|
|
continue-on-error: true
|
|
uses: github/codeql-action/upload-sarif@v4
|
|
with:
|
|
sarif_file: trivy-results.sarif
|
|
category: trivy-image
|
|
|
|
- name: Update Docker Hub description
|
|
# Only refresh README/description when we actually promote :latest
|
|
# (avoids overwriting from main pushes or back-fill builds).
|
|
if: needs.prepare.outputs.promote_latest == 'true'
|
|
uses: peter-evans/dockerhub-description@v5
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
repository: diegosouzapw/omniroute
|
|
short-description: "OmniRoute — Unified AI proxy. Route any LLM through one endpoint."
|
|
readme-filepath: ./README.md
|