Align CLI snapshot wording with version UI #1265
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |