zeroclaw/.github/workflows/pub-docker-img.yml

529 lines
23 KiB
YAML

name: Pub Docker Img
on:
push:
tags: ["v*"]
pull_request:
branches: [dev, main]
paths:
- "Dockerfile"
- ".dockerignore"
- "docker-compose.yml"
- "rust-toolchain.toml"
- "dev/config.template.toml"
- ".github/workflows/pub-docker-img.yml"
- ".github/release/ghcr-tag-policy.json"
- ".github/release/ghcr-vulnerability-policy.json"
- "scripts/ci/ghcr_publish_contract_guard.py"
- "scripts/ci/ghcr_vulnerability_gate.py"
workflow_dispatch:
inputs:
release_tag:
description: "Existing release tag to publish (e.g. v0.2.0). Leave empty for smoke-only run."
required: false
type: string
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: core.hooksPath
GIT_CONFIG_VALUE_0: /dev/null
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
TRIVY_IMAGE: aquasec/trivy:0.58.2
jobs:
pr-smoke:
name: PR Docker Smoke
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.release_tag == '')
runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 25
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Resolve Docker API version
shell: bash
run: |
set -euo pipefail
server_api="$(docker version --format '{{.Server.APIVersion}}')"
min_api="$(docker version --format '{{.Server.MinAPIVersion}}' 2>/dev/null || true)"
if [[ -z "${server_api}" || "${server_api}" == "<no value>" ]]; then
echo "::error::Unable to detect Docker server API version."
docker version || true
exit 1
fi
echo "DOCKER_API_VERSION=${server_api}" >> "$GITHUB_ENV"
echo "Using Docker API version ${server_api} (server min: ${min_api:-unknown})"
- name: Setup Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Extract metadata (tags, labels)
if: github.event_name == 'pull_request'
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=pr
- name: Build smoke image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: false
load: true
provenance: false
sbom: false
tags: zeroclaw-pr-smoke:latest
labels: ${{ steps.meta.outputs.labels || '' }}
platforms: linux/amd64
cache-from: type=gha,scope=pub-docker-pr-${{ github.event.pull_request.number || 'dispatch' }}
cache-to: type=gha,scope=pub-docker-pr-${{ github.event.pull_request.number || 'dispatch' }},mode=max
- name: Verify image
run: docker run --rm zeroclaw-pr-smoke:latest --version
publish:
name: Build and Push Docker Image
if: github.repository == 'zeroclaw-labs/zeroclaw' && ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != ''))
runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 90
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }}
- name: Resolve Docker API version
shell: bash
run: |
set -euo pipefail
server_api="$(docker version --format '{{.Server.APIVersion}}')"
min_api="$(docker version --format '{{.Server.MinAPIVersion}}' 2>/dev/null || true)"
if [[ -z "${server_api}" || "${server_api}" == "<no value>" ]]; then
echo "::error::Unable to detect Docker server API version."
docker version || true
exit 1
fi
echo "DOCKER_API_VERSION=${server_api}" >> "$GITHUB_ENV"
echo "Using Docker API version ${server_api} (server min: ${min_api:-unknown})"
- name: Setup Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
shell: bash
run: |
set -euo pipefail
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
echo "::error::Docker publish is restricted to v* tag pushes."
exit 1
fi
RELEASE_TAG="${GITHUB_REF#refs/tags/}"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
RELEASE_TAG="${{ inputs.release_tag }}"
if [[ -z "${RELEASE_TAG}" ]]; then
echo "::error::workflow_dispatch publish requires inputs.release_tag"
exit 1
fi
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::release_tag must be vX.Y.Z or vX.Y.Z-suffix (received: ${RELEASE_TAG})"
exit 1
fi
if ! git rev-parse --verify "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
echo "::error::release tag not found in checkout: ${RELEASE_TAG}"
exit 1
fi
else
echo "::error::Unsupported event for publish: ${GITHUB_EVENT_NAME}"
exit 1
fi
RELEASE_SHA="$(git rev-parse HEAD)"
SHA_SUFFIX="sha-${RELEASE_SHA::12}"
SHA_TAG="${IMAGE}:${SHA_SUFFIX}"
LATEST_SUFFIX="latest"
LATEST_TAG="${IMAGE}:${LATEST_SUFFIX}"
VERSION_TAG="${IMAGE}:${RELEASE_TAG}"
TAGS="${VERSION_TAG},${SHA_TAG},${LATEST_TAG}"
{
echo "tags=${TAGS}"
echo "release_tag=${RELEASE_TAG}"
echo "release_sha=${RELEASE_SHA}"
echo "sha_tag=${SHA_SUFFIX}"
echo "latest_tag=${LATEST_SUFFIX}"
} >> "$GITHUB_OUTPUT"
- name: Build release candidate image (pre-push scan)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: false
load: true
tags: zeroclaw-release-candidate:${{ steps.meta.outputs.release_tag }}
platforms: linux/amd64
cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }}
cache-to: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }},mode=max
- name: Pre-push Trivy gate (CRITICAL blocks, HIGH warns)
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
LOCAL_SCAN_IMAGE="zeroclaw-release-candidate:${{ steps.meta.outputs.release_tag }}"
docker run --rm \
-v "$PWD/artifacts:/work" \
"${TRIVY_IMAGE}" image \
--quiet \
--ignore-unfixed \
--severity CRITICAL \
--format json \
--output /work/trivy-prepush-critical.json \
"${LOCAL_SCAN_IMAGE}"
critical_count="$(python3 - <<'PY'
import json
from pathlib import Path
report = Path("artifacts/trivy-prepush-critical.json")
if not report.exists():
print(0)
raise SystemExit(0)
data = json.loads(report.read_text(encoding="utf-8"))
count = 0
for result in data.get("Results", []):
vulns = result.get("Vulnerabilities") or []
count += len(vulns)
print(count)
PY
)"
docker run --rm \
-v "$PWD/artifacts:/work" \
"${TRIVY_IMAGE}" image \
--quiet \
--ignore-unfixed \
--severity HIGH \
--format json \
--output /work/trivy-prepush-high.json \
"${LOCAL_SCAN_IMAGE}"
docker run --rm \
-v "$PWD/artifacts:/work" \
"${TRIVY_IMAGE}" image \
--quiet \
--ignore-unfixed \
--severity HIGH \
--format table \
--output /work/trivy-prepush-high.txt \
"${LOCAL_SCAN_IMAGE}"
high_count="$(python3 - <<'PY'
import json
from pathlib import Path
report = Path("artifacts/trivy-prepush-high.json")
if not report.exists():
print(0)
raise SystemExit(0)
data = json.loads(report.read_text(encoding="utf-8"))
count = 0
for result in data.get("Results", []):
vulns = result.get("Vulnerabilities") or []
count += len(vulns)
print(count)
PY
)"
{
echo "### Pre-push Trivy Gate"
echo "- Candidate image: \`${LOCAL_SCAN_IMAGE}\`"
echo "- CRITICAL findings: \`${critical_count}\` (blocking)"
echo "- HIGH findings: \`${high_count}\` (advisory)"
} >> "$GITHUB_STEP_SUMMARY"
if [ "${high_count}" -gt 0 ]; then
echo "::warning::Pre-push Trivy found ${high_count} HIGH vulnerabilities (advisory only)."
fi
if [ "${critical_count}" -gt 0 ]; then
echo "::error::Pre-push Trivy found ${critical_count} CRITICAL vulnerabilities."
exit 1
fi
- name: Build and push Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: true
build-args: |
ZEROCLAW_CARGO_ALL_FEATURES=true
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }}
cache-to: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }},mode=max
- name: Set GHCR package visibility to public
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner="${GITHUB_REPOSITORY_OWNER,,}"
repo="${GITHUB_REPOSITORY#*/}"
# Package path can vary depending on repository/package linkage.
candidates=(
"$repo"
"${owner}%2F${repo}"
)
for scope in orgs users; do
for pkg in "${candidates[@]}"; do
code="$(curl -sS -o /tmp/ghcr-visibility.json -w "%{http_code}" \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/${scope}/${owner}/packages/container/${pkg}/visibility" \
-d '{"visibility":"public"}' || true)"
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "GHCR package visibility is public (${scope}/${owner}/${pkg})."
exit 0
fi
echo "Visibility attempt ${scope}/${owner}/${pkg} returned HTTP ${code}."
done
done
echo "::warning::Unable to update GHCR visibility via API in this run; proceeding to GHCR publish contract verification."
- name: Validate GHCR publish contract
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
python3 scripts/ci/ghcr_publish_contract_guard.py \
--repository "${GITHUB_REPOSITORY,,}" \
--release-tag "${{ steps.meta.outputs.release_tag }}" \
--sha "${{ steps.meta.outputs.release_sha }}" \
--policy-file .github/release/ghcr-tag-policy.json \
--output-json artifacts/ghcr-publish-contract.json \
--output-md artifacts/ghcr-publish-contract.md \
--fail-on-violation
- name: Emit GHCR publish contract audit event
if: always()
shell: bash
run: |
set -euo pipefail
if [ -f artifacts/ghcr-publish-contract.json ]; then
python3 scripts/ci/emit_audit_event.py \
--event-type ghcr_publish_contract \
--input-json artifacts/ghcr-publish-contract.json \
--output-json artifacts/audit-event-ghcr-publish-contract.json \
--artifact-name ghcr-publish-contract \
--retention-days 21
fi
- name: Publish GHCR contract summary
if: always()
shell: bash
run: |
set -euo pipefail
if [ -f artifacts/ghcr-publish-contract.md ]; then
cat artifacts/ghcr-publish-contract.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload GHCR publish contract artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ghcr-publish-contract
path: |
artifacts/ghcr-publish-contract.json
artifacts/ghcr-publish-contract.md
artifacts/audit-event-ghcr-publish-contract.json
if-no-files-found: ignore
retention-days: 21
- name: Scan published image for policy evidence (Trivy)
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
TAG_NAME="${{ steps.meta.outputs.release_tag }}"
SHA_TAG="${{ steps.meta.outputs.sha_tag }}"
LATEST_TAG="${{ steps.meta.outputs.latest_tag }}"
IMAGE_BASE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
VERSION_REF="${IMAGE_BASE}:${TAG_NAME}"
SHA_REF="${IMAGE_BASE}:${SHA_TAG}"
LATEST_REF="${IMAGE_BASE}:${LATEST_TAG}"
SARIF_OUT="artifacts/trivy-${TAG_NAME}.sarif"
TABLE_OUT="artifacts/trivy-${TAG_NAME}.txt"
JSON_OUT="artifacts/trivy-${TAG_NAME}.json"
SHA_TABLE_OUT="artifacts/trivy-${SHA_TAG}.txt"
SHA_JSON_OUT="artifacts/trivy-${SHA_TAG}.json"
LATEST_TABLE_OUT="artifacts/trivy-${LATEST_TAG}.txt"
LATEST_JSON_OUT="artifacts/trivy-${LATEST_TAG}.json"
scan_trivy() {
local image_ref="$1"
local output_prefix="$2"
docker run --rm \
-v "$PWD/artifacts:/work" \
"${TRIVY_IMAGE}" image \
--quiet \
--ignore-unfixed \
--severity HIGH,CRITICAL \
--format json \
--output "/work/${output_prefix}.json" \
"${image_ref}"
docker run --rm \
-v "$PWD/artifacts:/work" \
"${TRIVY_IMAGE}" image \
--quiet \
--ignore-unfixed \
--severity HIGH,CRITICAL \
--format table \
--output "/work/${output_prefix}.txt" \
"${image_ref}"
}
docker run --rm \
-v "$PWD/artifacts:/work" \
"${TRIVY_IMAGE}" image \
--quiet \
--ignore-unfixed \
--severity HIGH,CRITICAL \
--format sarif \
--output "/work/trivy-${TAG_NAME}.sarif" \
"${VERSION_REF}"
scan_trivy "${VERSION_REF}" "trivy-${TAG_NAME}"
scan_trivy "${SHA_REF}" "trivy-${SHA_TAG}"
scan_trivy "${LATEST_REF}" "trivy-${LATEST_TAG}"
echo "Generated Trivy reports:"
ls -1 "$SARIF_OUT" "$TABLE_OUT" "$JSON_OUT" "$SHA_TABLE_OUT" "$SHA_JSON_OUT" "$LATEST_TABLE_OUT" "$LATEST_JSON_OUT"
- name: Validate GHCR vulnerability gate
shell: bash
run: |
set -euo pipefail
python3 scripts/ci/ghcr_vulnerability_gate.py \
--release-tag "${{ steps.meta.outputs.release_tag }}" \
--sha-tag "${{ steps.meta.outputs.sha_tag }}" \
--latest-tag "${{ steps.meta.outputs.latest_tag }}" \
--release-report-json "artifacts/trivy-${{ steps.meta.outputs.release_tag }}.json" \
--sha-report-json "artifacts/trivy-${{ steps.meta.outputs.sha_tag }}.json" \
--latest-report-json "artifacts/trivy-${{ steps.meta.outputs.latest_tag }}.json" \
--policy-file .github/release/ghcr-vulnerability-policy.json \
--output-json artifacts/ghcr-vulnerability-gate.json \
--output-md artifacts/ghcr-vulnerability-gate.md \
--fail-on-violation
- name: Emit GHCR vulnerability gate audit event
if: always()
shell: bash
run: |
set -euo pipefail
if [ -f artifacts/ghcr-vulnerability-gate.json ]; then
python3 scripts/ci/emit_audit_event.py \
--event-type ghcr_vulnerability_gate \
--input-json artifacts/ghcr-vulnerability-gate.json \
--output-json artifacts/audit-event-ghcr-vulnerability-gate.json \
--artifact-name ghcr-vulnerability-gate \
--retention-days 21
fi
- name: Publish GHCR vulnerability summary
if: always()
shell: bash
run: |
set -euo pipefail
if [ -f artifacts/ghcr-vulnerability-gate.md ]; then
cat artifacts/ghcr-vulnerability-gate.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload GHCR vulnerability gate artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ghcr-vulnerability-gate
path: |
artifacts/ghcr-vulnerability-gate.json
artifacts/ghcr-vulnerability-gate.md
artifacts/audit-event-ghcr-vulnerability-gate.json
if-no-files-found: ignore
retention-days: 21
- name: Detect Trivy SARIF report
id: trivy-sarif
if: always()
shell: bash
run: |
set -euo pipefail
sarif_path="artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif"
if [ -f "${sarif_path}" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::Trivy SARIF report not found at ${sarif_path}; skipping SARIF upload."
fi
- name: Upload Trivy SARIF
if: always() && steps.trivy-sarif.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
with:
sarif_file: artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif
category: ghcr-trivy
- name: Upload Trivy report artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ghcr-trivy-report
path: |
artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif
artifacts/trivy-${{ steps.meta.outputs.release_tag }}.txt
artifacts/trivy-${{ steps.meta.outputs.release_tag }}.json
artifacts/trivy-sha-*.txt
artifacts/trivy-sha-*.json
artifacts/trivy-latest.txt
artifacts/trivy-latest.json
artifacts/trivy-prepush-critical.json
artifacts/trivy-prepush-high.json
artifacts/trivy-prepush-high.txt
if-no-files-found: ignore
retention-days: 14