Skip to content

Align CLI snapshot wording with version UI #1265

Align CLI snapshot wording with version UI

Align CLI snapshot wording with version UI #1265

Workflow file for this run

name: Build and Push Docker Image
on:
pull_request:
push:
branches:
- "main"
tags:
- "v*"
permissions:
actions: read
contents: write
id-token: write
attestations: write
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# vars.* are not exposed to fork-PR runs on public repos, so fallbacks
# keep the image tag valid for the local build + smoke test.
REGISTRY: ${{ vars.DOCKER_REGISTRY || 'localhost' }}
PROJECT: ${{ vars.DOCKER_PROJECT || 'fork-pr' }}
IMAGE_NAME: nebi
jobs:
build:
name: Build (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
# Each platform builds, smoke-tests, and scans natively on its own
# runner (#417): no QEMU emulation, no cross-compilation.
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Load pinned tool versions
id: tool_versions
run: bash .github/scripts/load-tool-versions.sh
# linux/amd64 -> linux-amd64, used in cache scopes and artifact names
- name: Prepare platform slug
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Compute version
id: version
# git describe matches the format internal/api/handlers/version.go parses
# (e.g. v0.12-rc3-4-ge8f6759 -> 0.12-rc3.dev+e8f6759). fetch-depth: 0 above
# makes tags available. Without this the image bakes VERSION=dev and the
# UI shows "vdev".
run: echo "version=$(git describe --tags --always --dirty)" >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
env:
DOCKER_METADATA_PR_HEAD_SHA: true
with:
images: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}
tags: |
# PR builds: tag with pr-<number> and sha only
type=ref,event=pr
type=sha
# Main branch builds: add branch name and latest
type=ref,event=branch,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}}
# Release builds
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Determine registry publishing
id: registry
shell: bash
env:
DOCKER_REGISTRY_USERNAME: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
run: |
PUBLISH=false
if [[ '${{ github.event_name }}' != 'pull_request' ]]; then
PUBLISH=true
elif [[ -n "${DOCKER_REGISTRY_USERNAME}" && -n "${DOCKER_REGISTRY_PASSWORD}" ]]; then
PUBLISH=true
fi
echo "publish=${PUBLISH}" | tee --append "${GITHUB_OUTPUT}"
- name: Login to Quay.io
if: steps.registry.outputs.publish == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Select image reference
id: image
shell: bash
run: |
set -euo pipefail
first_tag="$(printf '%s\n' "${{ steps.meta.outputs.tags }}" | head -n 1)"
if [ -z "$first_tag" ]; then
echo "Docker metadata produced no tags."
exit 1
fi
echo "tag=${first_tag}" >> "$GITHUB_OUTPUT"
# Build locally only; the digest push happens in a later step so the
# smoke test and vulnerability scan gate what reaches the manifest list.
- name: Build image
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
build-args: |
VERSION=${{ steps.version.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
push: false
load: true
cache-from: ${{ steps.registry.outputs.publish == 'true' && format('type=gha,scope={0}', env.PLATFORM_PAIR) || '' }}
cache-to: ${{ steps.registry.outputs.publish == 'true' && format('type=gha,mode=max,scope={0}', env.PLATFORM_PAIR) || '' }}
provenance: false
sbom: false
- name: Smoke test image
run: |
set -e
IMAGE_TAG="${{ steps.image.outputs.tag }}"
echo "Testing image: ${IMAGE_TAG}"
trap 'docker rm -f test-nebi >/dev/null 2>&1 || true' EXIT
# Start container with port mapping
docker run --rm -d --name test-nebi \
-p 8460:8460 \
-e NEBI_AUTH_JWT_SECRET=test-secret-that-is-at-least-32-chars-long \
-e NEBI_DATABASE_DSN=/tmp/test.db \
"${IMAGE_TAG}"
# Wait for startup
sleep 10
# Check if container is still running
docker ps | grep test-nebi
# Check logs
docker logs test-nebi
# Test health endpoint from host (pixi image has no wget/curl)
curl -sf http://localhost:8460/api/v1/health || exit 1
# Guard: the image must report a real version, not the "dev" default.
# Catches a dropped VERSION build-arg (the bug that made the UI show "vdev").
VERSION_JSON=$(curl -sf http://localhost:8460/api/v1/version)
echo "Version endpoint: ${VERSION_JSON}"
REPORTED=$(echo "${VERSION_JSON}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["version"])')
echo "Reported version: ${REPORTED}"
if [ "${REPORTED}" = "dev" ] || [ -z "${REPORTED}" ]; then
echo "ERROR: image reports version '${REPORTED}' — VERSION build-arg was not injected"
exit 1
fi
# Cleanup
docker stop test-nebi
# Scan the locally built image (base layers + compiled binary) and fail
# on fixable High/Critical findings (#451). Runs before the push step so
# an image that fails the scan is never published.
- name: Scan image vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: image
image-ref: ${{ steps.image.outputs.tag }}
version: ${{ steps.tool_versions.outputs.TRIVY_VERSION }}
format: table
exit-code: '1'
ignore-unfixed: true
severity: HIGH,CRITICAL
# Push the image that passed the smoke test and scan, addressed by
# digest only (no tags yet). Rebuilds from this runner's BuildKit cache,
# so the pushed layers are the ones that were tested. The merge job
# stitches the per-platform digests into one signed manifest list.
- name: Push image by digest
id: push
if: steps.registry.outputs.publish == 'true'
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
build-args: |
VERSION=${{ steps.version.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
provenance: false
sbom: false
- name: Export digest
if: steps.registry.outputs.publish == 'true'
run: |
mkdir -p "${{ runner.temp }}/digests"
digest='${{ steps.push.outputs.digest }}'
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: steps.registry.outputs.publish == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-platform digests pushed above into a single manifest
# list. Runs only if every platform passed its smoke test and scan.
merge:
name: Push manifest list
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Load pinned tool versions
id: tool_versions
run: bash .github/scripts/load-tool-versions.sh
- name: Determine registry publishing
id: registry
shell: bash
env:
DOCKER_REGISTRY_USERNAME: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
run: |
PUBLISH=false
if [[ '${{ github.event_name }}' != 'pull_request' ]]; then
PUBLISH=true
elif [[ -n "${DOCKER_REGISTRY_USERNAME}" && -n "${DOCKER_REGISTRY_PASSWORD}" ]]; then
PUBLISH=true
fi
echo "publish=${PUBLISH}" | tee --append "${GITHUB_OUTPUT}"
- name: Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
env:
DOCKER_METADATA_PR_HEAD_SHA: true
with:
images: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}
tags: |
# PR builds: tag with pr-<number> and sha only
type=ref,event=pr
type=sha
# Main branch builds: add branch name and latest
type=ref,event=branch,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}}
# Release builds
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Download digests
if: steps.registry.outputs.publish == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
if: steps.registry.outputs.publish == 'true'
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to Quay.io
if: steps.registry.outputs.publish == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Set up Go for release image tools
if: steps.registry.outputs.publish == 'true'
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: 'go.mod'
- name: Install release image tools
if: steps.registry.outputs.publish == 'true'
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install "github.com/sigstore/cosign/v2/cmd/cosign@${COSIGN_VERSION}"
go install "github.com/google/go-containerregistry/cmd/crane@${CRANE_VERSION}"
go install "github.com/anchore/syft/cmd/syft@${SYFT_VERSION}"
- name: Create gated manifest list
id: manifest
if: steps.registry.outputs.publish == 'true'
shell: bash
working-directory: ${{ runner.temp }}/digests
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
candidate="${IMAGE}:publish-${GITHUB_SHA}-${GITHUB_RUN_ATTEMPT}"
refs=()
for digest_file in *; do
if [ ! -e "$digest_file" ]; then
echo "No image digests were downloaded."
exit 1
fi
refs+=("${IMAGE}@sha256:${digest_file}")
done
docker buildx imagetools create -t "$candidate" "${refs[@]}"
digest="$(docker buildx imagetools inspect "$candidate" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ -z "$digest" ] || [ "$digest" = "null" ]; then
echo "Could not determine manifest digest for ${candidate}."
exit 1
fi
echo "tag=${candidate}" >> "$GITHUB_OUTPUT"
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
- name: Generate image SBOM
if: steps.registry.outputs.publish == 'true'
run: syft "${{ steps.manifest.outputs.tag }}" -o spdx-json=nebi-container-image.sbom.spdx.json
# PR builds may publish temporary image refs when same-repo secrets are
# present; keep public signing and attestation records off PRs while #512
# tracks splitting release OIDC permissions into narrower workflows.
- name: Sign published image digest
if: github.event_name != 'pull_request' && steps.registry.outputs.publish == 'true'
run: cosign sign --yes "${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}"
- name: Attest published image digest
if: github.event_name != 'pull_request' && steps.registry.outputs.publish == 'true'
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # pinned commit
with:
subject-name: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
create-storage-record: false
- name: Attest published image SBOM
if: github.event_name != 'pull_request' && steps.registry.outputs.publish == 'true'
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # pinned commit
with:
subject-name: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
sbom-path: nebi-container-image.sbom.spdx.json
push-to-registry: true
create-storage-record: false
- name: Publish final image tags
id: final_tags
if: steps.registry.outputs.publish == 'true'
shell: bash
env:
FINAL_TAGS: ${{ steps.meta.outputs.tags }}
IMAGE: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}
MANIFEST_DIGEST: ${{ steps.manifest.outputs.digest }}
run: |
set -euo pipefail
first_tag=""
while IFS= read -r tag; do
[ -n "$tag" ] || continue
if [ -z "$first_tag" ]; then
first_tag="$tag"
echo "tag=${first_tag}" >> "$GITHUB_OUTPUT"
fi
docker buildx imagetools create -t "$tag" "${IMAGE}@${MANIFEST_DIGEST}"
pushed_digest="$(docker buildx imagetools inspect "$tag" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ "$pushed_digest" != "$MANIFEST_DIGEST" ]; then
echo "Final tag ${tag} points to ${pushed_digest}, expected signed digest ${MANIFEST_DIGEST}."
exit 1
fi
done <<< "$FINAL_TAGS"
if [ -z "$first_tag" ]; then
echo "Docker metadata produced no tags."
exit 1
fi
- name: Delete gated manifest candidate on failure
if: failure() && steps.manifest.outputs.tag != ''
shell: bash
run: crane delete "${{ steps.manifest.outputs.tag }}"
- name: Inspect manifest list
if: steps.registry.outputs.publish == 'true'
run: docker buildx imagetools inspect "${{ steps.final_tags.outputs.tag }}"
- name: Sign container image release metadata
if: startsWith(github.ref, 'refs/tags/') && steps.registry.outputs.publish == 'true'
shell: bash
run: |
set -euo pipefail
image="${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}"
digest="${{ steps.manifest.outputs.digest }}"
cat > nebi-container-image.json <<EOF
{
"component": "container",
"image": "${image}",
"digest": "${digest}",
"reference": "${image}@${digest}",
"source_commit": "${GITHUB_SHA}",
"workflow": "${GITHUB_WORKFLOW}",
"run_id": "${GITHUB_RUN_ID}",
"run_attempt": "${GITHUB_RUN_ATTEMPT}"
}
EOF
cosign sign-blob --bundle=nebi-container-image.json.sigstore.json nebi-container-image.json --yes
- name: Attest container image release metadata
if: startsWith(github.ref, 'refs/tags/') && steps.registry.outputs.publish == 'true'
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # pinned commit
with:
subject-path: nebi-container-image.json
create-storage-record: false
- name: Wait for CLI release draft
if: startsWith(github.ref, 'refs/tags/') && steps.registry.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash .github/scripts/wait-for-release-workflow.sh Release
- name: Upload container image release metadata
if: startsWith(github.ref, 'refs/tags/') && steps.registry.outputs.publish == 'true'
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
draft: true
fail_on_unmatched_files: true
files: |
nebi-container-image.json
nebi-container-image.json.sigstore.json
- name: Publish release when all gated assets are current
if: startsWith(github.ref, 'refs/tags/') && steps.registry.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash .github/scripts/publish-release-if-ready.sh
- name: Add image tags to summary
run: |
{
echo "## Docker Image Built: ${{ env.IMAGE_NAME }}"
echo ""
if [ "${{ steps.registry.outputs.publish }}" = "true" ]; then
echo "**Platforms:** linux/amd64, linux/arm64"
echo "**Digest:** ${{ steps.manifest.outputs.digest }}"
else
echo "**Platforms:** linux/amd64, linux/arm64 (local build only, not published)"
fi
echo ""
echo "### Pull Command:"
echo '```bash'
echo "docker pull ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}"
echo '```'
echo ""
echo "### All Tags:"
echo '```'
printf '%s\n' "${{ steps.meta.outputs.tags }}" | sed 's/,/\n/g'
echo '```'
} >> "$GITHUB_STEP_SUMMARY"