diff --git a/.github/workflows/docker-build-check.yml b/.github/workflows/docker-build-check.yml
index 34da220fc..967e66a45 100644
--- a/.github/workflows/docker-build-check.yml
+++ b/.github/workflows/docker-build-check.yml
@@ -16,18 +16,31 @@ on:
- 'dstack/**'
- 'rust-toolchain.toml'
- '.github/workflows/docker-build-check.yml'
+ - '.github/workflows/kms-release.yml'
+ - '.github/workflows/gateway-release.yml'
+ - '.github/workflows/verifier-release.yml'
pull_request:
branches: [ next, 'release/**' ]
paths:
- 'dstack/**'
- 'rust-toolchain.toml'
- '.github/workflows/docker-build-check.yml'
+ - '.github/workflows/kms-release.yml'
+ - '.github/workflows/gateway-release.yml'
+ - '.github/workflows/verifier-release.yml'
env:
DSTACK_REV: ${{ github.event.pull_request.head.sha || github.sha }}
DSTACK_SRC_URL: ${{ github.event.pull_request.head.repo.clone_url || format('{0}/{1}', github.server_url, github.repository) }}
jobs:
+ build-script-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - name: Test release build orchestration
+ run: python3 -m unittest discover -s dstack/build/shared/tests -v
+
gateway:
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/gateway-release.yml b/.github/workflows/gateway-release.yml
index 07d12796f..998209bc5 100644
--- a/.github/workflows/gateway-release.yml
+++ b/.github/workflows/gateway-release.yml
@@ -6,6 +6,11 @@ name: Gateway Release
on:
workflow_dispatch:
+ inputs:
+ version:
+ description: Existing release tag version without the gateway-v prefix
+ required: true
+ type: string
push:
tags:
- 'gateway-v*'
@@ -19,15 +24,30 @@ jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
+ - name: Parse and check version
+ env:
+ DISPATCH_VERSION: ${{ inputs.version }}
+ run: |
+ if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
+ VERSION=$DISPATCH_VERSION
+ else
+ case "$GITHUB_REF" in
+ refs/tags/gateway-v*) VERSION=${GITHUB_REF#refs/tags/gateway-v} ;;
+ *) echo "Expected a gateway-v* tag" >&2; exit 1 ;;
+ esac
+ fi
+ if [[ ! "$VERSION" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
+ echo "Version must be a valid Docker tag" >&2
+ exit 1
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+ echo "RELEASE_TAG=gateway-v$VERSION" >> "$GITHUB_ENV"
+
+ # Manual runs rebuild an existing release tag, not the selected branch.
- name: Checkout repository
uses: actions/checkout@v5
-
- - name: Parse version from tag
- run: |
- # Extract version from tag (e.g., gateway-v1.2.3 -> 1.2.3)
- VERSION=${GITHUB_REF#refs/tags/gateway-v}
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- echo "Parsed version: $VERSION"
+ with:
+ ref: ${{ github.event_name == 'workflow_dispatch' && env.RELEASE_TAG || github.sha }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
@@ -38,27 +58,31 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Get Git commit timestamps
- run: |
- echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV
- echo "GIT_REV=$(git rev-parse HEAD)" >> $GITHUB_ENV
+ - name: Resolve the revision to build
+ run: echo "GIT_REV=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ # Release images are built through the same script contributors run by
+ # hand, so a published image can be reproduced and checked digest-for-digest.
+ # See the builder README for the reproduction recipe.
- name: Build and push Docker image
id: build-and-push
- uses: docker/build-push-action@v5
env:
- SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }}
- with:
- context: dstack/gateway/dstack-app/builder
- push: true
- tags: ${{ vars.DOCKERHUB_ORG }}/dstack-gateway:${{ env.VERSION }}
- platforms: linux/amd64
- provenance: false
- build-contexts: |
- build-shared=dstack/build/shared
- build-args: |
- DSTACK_REV=${{ env.GIT_REV }}
- SOURCE_DATE_EPOCH=${{ env.TIMESTAMP }}
+ PUSH: "1"
+ METADATA_FILE: ${{ runner.temp }}/build-metadata.json
+ GIT_REV: ${{ env.GIT_REV }}
+ IMAGE_VERSION: ${{ env.VERSION }}
+ IMAGE_NAME: ${{ vars.DOCKERHUB_ORG }}/dstack-gateway:${{ env.VERSION }}
+ IMAGE_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}
+ DSTACK_SRC_URL: ${{ github.server_url }}/${{ github.repository }}.git
+ run: |
+ dstack/gateway/dstack-app/builder/build-image.sh \
+ "$IMAGE_NAME"
+ digest=$(jq -r '."containerimage.digest"' "$METADATA_FILE")
+ if [ -z "$digest" ] || [ "$digest" = null ]; then
+ echo "the build did not report an image digest" >&2
+ exit 1
+ fi
+ echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
@@ -70,6 +94,8 @@ jobs:
- name: GitHub Release
uses: softprops/action-gh-release@v2
with:
+ tag_name: ${{ env.RELEASE_TAG }}
+ target_commitish: ${{ env.GIT_REV }}
name: "Gateway Release v${{ env.VERSION }}"
body: |
## Docker Image Information
diff --git a/.github/workflows/kms-release.yml b/.github/workflows/kms-release.yml
index 0b33c5f88..122d886b6 100644
--- a/.github/workflows/kms-release.yml
+++ b/.github/workflows/kms-release.yml
@@ -6,6 +6,11 @@ name: KMS Release
on:
workflow_dispatch:
+ inputs:
+ version:
+ description: Existing release tag version without the kms-v prefix
+ required: true
+ type: string
push:
tags:
- 'kms-v*'
@@ -19,8 +24,30 @@ jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
+ - name: Parse and check version
+ env:
+ DISPATCH_VERSION: ${{ inputs.version }}
+ run: |
+ if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
+ VERSION=$DISPATCH_VERSION
+ else
+ case "$GITHUB_REF" in
+ refs/tags/kms-v*) VERSION=${GITHUB_REF#refs/tags/kms-v} ;;
+ *) echo "Expected a kms-v* tag" >&2; exit 1 ;;
+ esac
+ fi
+ if [[ ! "$VERSION" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
+ echo "Version must be a valid Docker tag" >&2
+ exit 1
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+ echo "RELEASE_TAG=kms-v$VERSION" >> "$GITHUB_ENV"
+
+ # Manual runs rebuild an existing release tag, not the selected branch.
- name: Checkout repository
uses: actions/checkout@v5
+ with:
+ ref: ${{ github.event_name == 'workflow_dispatch' && env.RELEASE_TAG || github.sha }}
- name: Checkout contract dependencies
run: |
@@ -29,13 +56,6 @@ jobs:
dstack/kms/auth-eth/lib/openzeppelin-contracts-upgradeable \
dstack/kms/auth-eth/lib/openzeppelin-foundry-upgrades
- - name: Parse version from tag
- run: |
- # Extract version from tag (e.g., kms-v1.2.3 -> 1.2.3)
- VERSION=${GITHUB_REF#refs/tags/kms-v}
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- echo "Parsed version: $VERSION"
-
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
@@ -45,28 +65,31 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Get Git commit timestamps
- run: |
- echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV
- echo "GIT_REV=$(git rev-parse HEAD)" >> $GITHUB_ENV
+ - name: Resolve the revision to build
+ run: echo "GIT_REV=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ # Release images are built through the same script contributors run by
+ # hand, so a published image can be reproduced and checked digest-for-digest.
+ # See the builder README for the reproduction recipe.
- name: Build and push Docker image
id: build-and-push
- uses: docker/build-push-action@v5
env:
- SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }}
- with:
- context: dstack/kms/dstack-app/builder
- push: true
- tags: ${{ vars.DOCKERHUB_ORG }}/dstack-kms:${{ env.VERSION }}
- platforms: linux/amd64
- provenance: false
- build-contexts: |
- build-shared=dstack/build/shared
- build-args: |
- DSTACK_REV=${{ env.GIT_REV }}
- DSTACK_SRC_URL=${{ github.server_url }}/${{ github.repository }}
- SOURCE_DATE_EPOCH=${{ env.TIMESTAMP }}
+ PUSH: "1"
+ METADATA_FILE: ${{ runner.temp }}/build-metadata.json
+ GIT_REV: ${{ env.GIT_REV }}
+ IMAGE_VERSION: ${{ env.VERSION }}
+ IMAGE_NAME: ${{ vars.DOCKERHUB_ORG }}/dstack-kms:${{ env.VERSION }}
+ IMAGE_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}
+ DSTACK_SRC_URL: ${{ github.server_url }}/${{ github.repository }}.git
+ run: |
+ dstack/kms/dstack-app/builder/build-image.sh \
+ "$IMAGE_NAME"
+ digest=$(jq -r '."containerimage.digest"' "$METADATA_FILE")
+ if [ -z "$digest" ] || [ "$digest" = null ]; then
+ echo "the build did not report an image digest" >&2
+ exit 1
+ fi
+ echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
@@ -87,6 +110,8 @@ jobs:
- name: GitHub Release
uses: softprops/action-gh-release@v2
with:
+ tag_name: ${{ env.RELEASE_TAG }}
+ target_commitish: ${{ env.GIT_REV }}
name: "KMS Release v${{ env.VERSION }}"
files: |
dstack/kms/auth-eth/out/DstackKms.sol/DstackKms.json
diff --git a/.github/workflows/verifier-release.yml b/.github/workflows/verifier-release.yml
index d02d5b3c2..857a6382c 100644
--- a/.github/workflows/verifier-release.yml
+++ b/.github/workflows/verifier-release.yml
@@ -6,6 +6,11 @@ name: Verifier Release
on:
workflow_dispatch:
+ inputs:
+ version:
+ description: Existing release tag version without the verifier-v prefix
+ required: true
+ type: string
push:
tags:
- 'verifier-v*'
@@ -19,14 +24,30 @@ jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
+ - name: Parse and check version
+ env:
+ DISPATCH_VERSION: ${{ inputs.version }}
+ run: |
+ if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
+ VERSION=$DISPATCH_VERSION
+ else
+ case "$GITHUB_REF" in
+ refs/tags/verifier-v*) VERSION=${GITHUB_REF#refs/tags/verifier-v} ;;
+ *) echo "Expected a verifier-v* tag" >&2; exit 1 ;;
+ esac
+ fi
+ if [[ ! "$VERSION" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
+ echo "Version must be a valid Docker tag" >&2
+ exit 1
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+ echo "RELEASE_TAG=verifier-v$VERSION" >> "$GITHUB_ENV"
+
+ # Manual runs rebuild an existing release tag, not the selected branch.
- name: Checkout repository
uses: actions/checkout@v5
-
- - name: Parse version from tag
- run: |
- VERSION=${GITHUB_REF#refs/tags/verifier-v}
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- echo "Parsed version: $VERSION"
+ with:
+ ref: ${{ github.event_name == 'workflow_dispatch' && env.RELEASE_TAG || github.sha }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
@@ -37,31 +58,33 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Get Git commit timestamps
- run: |
- echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV
- echo "GIT_REV=$(git rev-parse HEAD)" >> $GITHUB_ENV
+ - name: Resolve the revision to build
+ run: echo "GIT_REV=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ # Release images are built through the same script contributors run by
+ # hand, so a published image can be reproduced and checked digest-for-digest.
+ # See the builder README for the reproduction recipe.
- name: Build and push Docker image
id: build-and-push
- uses: docker/build-push-action@v5
env:
- SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }}
- with:
- context: dstack/verifier
- file: dstack/verifier/builder/Dockerfile
- push: true
- tags: |
- ${{ vars.DOCKERHUB_ORG }}/dstack-verifier:${{ env.VERSION }}
- ${{ vars.DOCKERHUB_ORG }}/dstack-verifier:latest
- platforms: linux/amd64
- provenance: false
- build-contexts: |
- build-shared=dstack/build/shared
- build-args: |
- DSTACK_REV=${{ env.GIT_REV }}
- DSTACK_SRC_URL=${{ github.server_url }}/${{ github.repository }}.git
- SOURCE_DATE_EPOCH=${{ env.TIMESTAMP }}
+ PUSH: "1"
+ METADATA_FILE: ${{ runner.temp }}/build-metadata.json
+ GIT_REV: ${{ env.GIT_REV }}
+ IMAGE_VERSION: ${{ env.VERSION }}
+ IMAGE_NAME: ${{ vars.DOCKERHUB_ORG }}/dstack-verifier:${{ env.VERSION }}
+ LATEST_IMAGE_NAME: ${{ vars.DOCKERHUB_ORG }}/dstack-verifier:latest
+ IMAGE_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}
+ DSTACK_SRC_URL: ${{ github.server_url }}/${{ github.repository }}.git
+ run: |
+ dstack/verifier/builder/build-image.sh \
+ "$IMAGE_NAME" \
+ "$LATEST_IMAGE_NAME"
+ digest=$(jq -r '."containerimage.digest"' "$METADATA_FILE")
+ if [ -z "$digest" ] || [ "$digest" = null ]; then
+ echo "the build did not report an image digest" >&2
+ exit 1
+ fi
+ echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
@@ -73,6 +96,8 @@ jobs:
- name: GitHub Release
uses: softprops/action-gh-release@v2
with:
+ tag_name: ${{ env.RELEASE_TAG }}
+ target_commitish: ${{ env.GIT_REV }}
name: "Verifier Release v${{ env.VERSION }}"
body: |
## Docker Image Information
diff --git a/dstack/build/shared/build-lib.sh b/dstack/build/shared/build-lib.sh
index d24d368d4..51c7dcb50 100755
--- a/dstack/build/shared/build-lib.sh
+++ b/dstack/build/shared/build-lib.sh
@@ -6,12 +6,28 @@
# Shared build library for reproducible Docker image builds.
#
+# Release CI runs the very same component build-image.sh that a user runs by
+# hand, so everything that ends up in the published image -- layers, config
+# labels and manifest annotations -- is reproducible outside CI.
+#
# Expected variables (set by the sourcing script):
# REPO_ROOT - absolute path to the monorepo root
# CONTEXT_DIR - Docker build context directory
# DOCKERFILE - path to the Dockerfile
# GIT_REV - git revision to build
# DSTACK_SRC_URL - git URL for dstack source
+#
+# Optional variables:
+# IMAGE_VERSION - version recorded in the image metadata (default: dev).
+# Release CI passes the tag version; pass the same value to
+# reproduce a published image.
+# IMAGE_SOURCE_URL - repository URL recorded in the image metadata
+# PUSH - non-empty to publish after local validation
+# OCI_TAR - path to also write an OCI archive to. The manifest digest
+# of that archive is what a registry reports, so this is the
+# way to check a local rebuild against a published digest.
+# METADATA_FILE - path buildx writes its build metadata (incl. digest) to
+# NO_CACHE - non-empty to build without the layer cache
set -euo pipefail
@@ -36,10 +52,65 @@ extract_packages() {
>"$pkg_list_file"
}
+# Print the OCI metadata for a component image as "key=value" lines.
+#
+# This is the single source of truth for that metadata: docker_build feeds the
+# result to the config labels, the manifest annotations and the in-image
+# /etc/
/build-info file, so the three can never disagree.
+#
+# $1 - image title, e.g. dstack-kms
+# $2 - one-line description
+# $3 - component directory within the repo, e.g. dstack/kms
+# $4 - documentation file within the repo, e.g. dstack/kms/README.md
+image_metadata() {
+ local title=$1
+ local description=$2
+ local component_dir=$3
+ local documentation=$4
+
+ local source_url=${IMAGE_SOURCE_URL:-${DSTACK_SRC_URL%.git}}
+
+ # Read the pinned base image out of the Dockerfile's last FROM so the
+ # base.name/base.digest claims cannot drift from the layer they describe.
+ local base_ref
+ base_ref=$(awk '$1 == "FROM" { ref = $2 } END { print ref }' "$DOCKERFILE")
+ if [[ "$base_ref" != *@sha256:* ]]; then
+ echo "the final FROM in $DOCKERFILE must pin a digest; got '$base_ref'" >&2
+ return 1
+ fi
+ local base_name=${base_ref%@*}
+ local base_digest=${base_ref#*@}
+ # Normalize a bare official-image reference to its canonical form.
+ if [[ "$base_name" != */* ]]; then
+ base_name="docker.io/library/$base_name"
+ fi
+
+ printf '%s\n' \
+ "org.opencontainers.image.title=$title" \
+ "org.opencontainers.image.description=$description" \
+ "org.opencontainers.image.source=$source_url" \
+ "org.opencontainers.image.revision=$GIT_REV" \
+ "org.opencontainers.image.version=${IMAGE_VERSION:-dev}" \
+ "org.opencontainers.image.url=$source_url/tree/$GIT_REV/$component_dir" \
+ "org.opencontainers.image.documentation=$source_url/blob/$GIT_REV/$documentation" \
+ "org.opencontainers.image.licenses=Apache-2.0" \
+ "org.opencontainers.image.base.name=$base_name" \
+ "org.opencontainers.image.base.digest=$base_digest"
+}
+
+# docker_build [target] [pkg_list_file] [metadata] [mode]
+#
+# tags - newline-separated list of image tags
+# target - build stage to stop at, empty for the final image
+# pkg_list_file - where to record the installed Debian packages
+# metadata - "key=value" lines from image_metadata, empty to record none
+# mode - load (default), or export after package validation
docker_build() {
- local image_name=$1
+ local tags=$1
local target=${2:-}
local pkg_list_file=${3:-}
+ local metadata=${4:-}
+ local mode=${5:-load}
local commit_timestamp
commit_timestamp=$(git -C "$REPO_ROOT" show -s --format=%ct "$GIT_REV")
@@ -47,26 +118,84 @@ docker_build() {
local args=(
--builder "$BUILDKIT_BUILDER"
--progress=plain
- --output "type=docker,name=$image_name,rewrite-timestamp=true"
+ # The Rust stages cross-compile to x86_64-unknown-linux-musl, so pin the
+ # platform rather than inheriting the host's -- an arm64 workstation must
+ # still reproduce the released amd64 image.
+ --platform linux/amd64
+ # BuildKit attaches a provenance attestation by default when pushing,
+ # which carries build timestamps and turns the pushed tag into an index.
+ # Both would make the digest unreproducible.
+ --provenance=false
--build-context "build-shared=$BUILD_SHARED_DIR"
--build-arg "SOURCE_DATE_EPOCH=$commit_timestamp"
--build-arg "DSTACK_REV=$GIT_REV"
--build-arg "DSTACK_SRC_URL=$DSTACK_SRC_URL"
)
- if [ -n "${NO_CACHE:-}" ]; then
- args+=(--no-cache)
+ local tag
+ while IFS= read -r tag; do
+ [ -n "$tag" ] || continue
+ args+=(--tag "$tag")
+ done <<<"$tags"
+
+ if [ -n "$metadata" ]; then
+ local pair
+ while IFS= read -r pair; do
+ [ -n "$pair" ] || continue
+ args+=(--label "$pair" --annotation "manifest:$pair")
+ done <<<"$metadata"
+ args+=(--build-arg "IMAGE_METADATA=$metadata")
fi
+ # A validation build has only a Docker exporter. Never mix it with an OCI
+ # exporter: on classic Docker stores its digest overwrites the OCI digest in
+ # BuildKit's metadata response. Export the validated, cached result separately.
+ local outputs=()
+ case "$mode" in
+ load)
+ outputs+=("type=docker,rewrite-timestamp=true")
+ if [ -n "${NO_CACHE:-}" ]; then
+ args+=(--no-cache)
+ fi
+ ;;
+ export)
+ if [ -n "$target" ] || [ -n "$pkg_list_file" ]; then
+ echo "only validated final images may be exported" >&2
+ return 1
+ fi
+ if [ -n "${OCI_TAR:-}" ]; then
+ outputs+=("type=oci,oci-mediatypes=true,rewrite-timestamp=true,dest=$OCI_TAR")
+ fi
+ if [ -n "${PUSH:-}" ]; then
+ outputs+=("type=image,push=true,oci-mediatypes=true,rewrite-timestamp=true")
+ fi
+ if [ "${#outputs[@]}" -eq 0 ]; then
+ # METADATA_FILE alone still reports the registry-compatible OCI
+ # digest, not the manifest produced by a classic Docker exporter.
+ outputs+=("type=image,push=false,oci-mediatypes=true,rewrite-timestamp=true")
+ fi
+ if [ -n "${METADATA_FILE:-}" ]; then
+ args+=(--metadata-file "$METADATA_FILE")
+ fi
+ ;;
+ *)
+ echo "unknown build mode: $mode" >&2
+ return 1
+ ;;
+ esac
+
if [ -n "$target" ]; then
args+=(--target "$target")
fi
- docker buildx build "${args[@]}" \
- --file "$DOCKERFILE" \
- "$CONTEXT_DIR"
+ local output
+ for output in "${outputs[@]}"; do
+ docker buildx build "${args[@]}" --output "$output" \
+ --file "$DOCKERFILE" \
+ "$CONTEXT_DIR"
+ done
- extract_packages "$image_name" "$pkg_list_file"
+ extract_packages "$(head -n1 <<<"$tags")" "$pkg_list_file"
}
# Verify that pinned-packages files haven't changed (idempotency check).
@@ -81,3 +210,30 @@ check_clean_tree() {
exit 1
fi
}
+
+# Build and validate all package lists before exporting or updating remote tags.
+# The export reuses the validated cache even when NO_CACHE was requested for the
+# validation builds. check_clean_tree ensures extraction did not change inputs.
+build_component() {
+ local tags=$1
+ local builder_tag=$2
+ local builder_target=$3
+ local shared_dir=$4
+ local metadata=$5
+
+ if [ -n "${PUSH:-}${OCI_TAR:-}${METADATA_FILE:-}" ]; then
+ # Do not let package extraction erase pre-existing changes and make an
+ # unvalidated set of build inputs appear clean before publication.
+ check_clean_tree "$shared_dir"
+ fi
+
+ # Build the intermediate stage first: with NO_CACHE it must not replace the
+ # final image's cached dependencies between validation and export.
+ docker_build "$builder_tag" "$builder_target" "$shared_dir/builder-pinned-packages.txt"
+ docker_build "$tags" "" "$shared_dir/pinned-packages.txt" "$metadata"
+ check_clean_tree "$shared_dir"
+
+ if [ -n "${PUSH:-}${OCI_TAR:-}${METADATA_FILE:-}" ]; then
+ docker_build "$tags" "" "" "$metadata" export
+ fi
+}
diff --git a/dstack/build/shared/tests/test_build_lib.py b/dstack/build/shared/tests/test_build_lib.py
new file mode 100644
index 000000000..4e907a619
--- /dev/null
+++ b/dstack/build/shared/tests/test_build_lib.py
@@ -0,0 +1,268 @@
+# SPDX-FileCopyrightText: © 2026 Phala Network
+#
+# SPDX-License-Identifier: Apache-2.0
+
+"""Exercise release orchestration without Docker or network access.
+
+Run with: python3 -m unittest discover -s dstack/build/shared/tests -v
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[4]
+COMPONENTS = ("kms/dstack-app", "gateway/dstack-app", "verifier")
+MOCK_DOCKER = r"""
+import json
+import os
+import sys
+
+args = sys.argv[1:]
+with open(os.environ["CALLS"], "a") as log:
+ log.write(json.dumps(args) + "\n")
+if args[:2] == ["buildx", "build"]:
+ stage = "builder" if "--target" in args else "final"
+ if os.environ.get("FAIL_BUILD") == stage:
+ sys.exit(1)
+ if "--metadata-file" in args:
+ with open(args[args.index("--metadata-file") + 1], "w") as out:
+ json.dump({"containerimage.digest": "sha256:" + "1" * 64}, out)
+elif args[0] == "run":
+ stage = "builder" if args[4].endswith("-builder-temp") else "final"
+ if os.environ.get("FAIL_EXTRACT") == stage:
+ sys.exit(1)
+ print("package=" + ("2" if os.environ.get("DRIFT") == stage else "1"))
+"""
+
+
+class BuildTests(unittest.TestCase):
+ """Check the real component entry points with a recording Docker executable."""
+
+ def setUp(self):
+ """Create a minimal tracked checkout and an annotated release tag."""
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ shared = self.root / "dstack/build/shared"
+ shared.mkdir(parents=True)
+ shutil.copy(ROOT / "dstack/build/shared/build-lib.sh", shared)
+ for component in COMPONENTS:
+ builder = self.root / "dstack" / component / "builder"
+ (builder / "shared").mkdir(parents=True)
+ shutil.copy(ROOT / "dstack" / component / "builder/build-image.sh", builder)
+ (builder / "Dockerfile").write_text(
+ "FROM debian:bookworm@sha256:" + "0" * 64 + "\n"
+ )
+ for name in ("pinned-packages.txt", "builder-pinned-packages.txt"):
+ (builder / "shared" / name).write_text("package=1\n")
+ self.git("init", "-q")
+ self.git("add", ".")
+ tree = self.git("write-tree")
+ # Synthetic objects avoid depending on or overriding any Git identity.
+ identity = "Build Fixture 1700000000 +0000"
+ self.commit = self.git(
+ "hash-object",
+ "-t",
+ "commit",
+ "-w",
+ "--stdin",
+ input=f"tree {tree}\nauthor {identity}\ncommitter {identity}\n\nFixture\n",
+ )
+ self.git("update-ref", "HEAD", self.commit)
+ tag = self.git(
+ "hash-object",
+ "-t",
+ "tag",
+ "-w",
+ "--stdin",
+ input=f"object {self.commit}\ntype commit\ntag release\ntagger {identity}\n\nRelease\n",
+ )
+ self.git("update-ref", "refs/tags/release", tag)
+ bin_dir = self.root / "bin"
+ bin_dir.mkdir()
+ docker = bin_dir / "docker"
+ docker.write_text(f"#!{sys.executable}\n" + MOCK_DOCKER)
+ docker.chmod(0o755)
+ self.calls = self.root / "calls.jsonl"
+ self.metadata = self.root / "metadata.json"
+ self.env = {
+ **os.environ,
+ "PATH": f"{bin_dir}:{os.environ['PATH']}",
+ "CALLS": str(self.calls),
+ "GIT_REV": "release",
+ "IMAGE_VERSION": "1.0",
+ "PUSH": "",
+ "OCI_TAR": "",
+ "METADATA_FILE": "",
+ "NO_CACHE": "",
+ "FAIL_BUILD": "",
+ "FAIL_EXTRACT": "",
+ "DRIFT": "",
+ }
+
+ def git(self, *args, input=None):
+ """Run Git only against the disposable fixture repository."""
+ return subprocess.check_output(
+ ["git", "-C", str(self.root), *args],
+ input=input,
+ text=True,
+ ).strip()
+
+ def run_build(self, component="kms/dstack-app", **env):
+ """Run a real entry point and return its result and recorded build calls."""
+ self.calls.unlink(missing_ok=True)
+ result = subprocess.run(
+ [
+ "bash",
+ str(self.root / "dstack" / component / "builder/build-image.sh"),
+ "example/probe:1.0",
+ "example/probe:latest",
+ ],
+ env={**self.env, **env},
+ text=True,
+ capture_output=True,
+ )
+ calls = [json.loads(line) for line in self.calls.read_text().splitlines()]
+ builds = [call for call in calls if call[:2] == ["buildx", "build"]]
+ return result, builds
+
+ def test_local_build_peels_annotated_tags(self):
+ """All components resolve annotated tags and load only the two stages."""
+ for component in COMPONENTS:
+ with self.subTest(component=component):
+ result, builds = self.run_build(component)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(len(builds), 2)
+ for build in builds:
+ self.assertIn(f"DSTACK_REV={self.commit}", build)
+ self.assertIn("SOURCE_DATE_EPOCH=1700000000", build)
+ self.assertEqual(build.count("--output"), 1)
+ self.assertIn("type=docker,rewrite-timestamp=true", build)
+ self.assertNotIn("--metadata-file", build)
+
+ def test_exports_are_separate_and_follow_validation(self):
+ """A no-cache request does not force a fresh build after validation."""
+ result, builds = self.run_build(
+ PUSH="1",
+ OCI_TAR=str(self.root / "image.tar"),
+ METADATA_FILE=str(self.metadata),
+ NO_CACHE="1",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(len(builds), 4)
+ self.assertIn("--target", builds[0])
+ self.assertNotIn("--target", builds[1])
+ for build in builds[:2]:
+ self.assertIn("--no-cache", build)
+ self.assertNotIn("--metadata-file", build)
+ for build in builds[2:]:
+ self.assertEqual(build.count("--output"), 1)
+ self.assertNotIn("--no-cache", build)
+ self.assertNotIn("--target", build)
+ self.assertNotIn("type=docker,rewrite-timestamp=true", build)
+ self.assertIn("--metadata-file", build)
+ self.assertIn("example/probe:1.0", build)
+ self.assertIn("example/probe:latest", build)
+ self.assertTrue(
+ builds[2][builds[2].index("--output") + 1].startswith("type=oci,")
+ )
+ self.assertIn(
+ "type=image,push=true,oci-mediatypes=true,rewrite-timestamp=true", builds[3]
+ )
+ self.assertTrue(self.metadata.exists())
+
+ def test_failures_never_publish(self):
+ """Build errors, extraction errors and either drifting list block export."""
+ for variable in ("FAIL_BUILD", "FAIL_EXTRACT", "DRIFT"):
+ for stage in ("final", "builder"):
+ with self.subTest(variable=variable, stage=stage):
+ self.git("restore", ".")
+ result, builds = self.run_build(
+ PUSH="1",
+ METADATA_FILE=str(self.metadata),
+ **{variable: stage},
+ )
+ self.assertNotEqual(result.returncode, 0)
+ for build in builds:
+ self.assertIn("type=docker,rewrite-timestamp=true", build)
+ self.assertNotIn("--metadata-file", build)
+ self.assertFalse(self.metadata.exists())
+
+ def test_dirty_inputs_block_publication_before_building(self):
+ """Extraction must not be allowed to erase dirty publication inputs."""
+ pins = self.root / "dstack/kms/dstack-app/builder/shared/pinned-packages.txt"
+ pins.write_text("package=2\n")
+ result, builds = self.run_build(PUSH="1")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(builds, [])
+ self.assertEqual(pins.read_text(), "package=2\n")
+
+ def test_metadata_only_uses_oci_image_exporter(self):
+ """Requesting metadata alone never reports a Docker schema manifest."""
+ result, builds = self.run_build(METADATA_FILE=str(self.metadata))
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(len(builds), 3)
+ self.assertIn(
+ "type=image,push=false,oci-mediatypes=true,rewrite-timestamp=true",
+ builds[-1],
+ )
+
+
+class WorkflowTests(unittest.TestCase):
+ """Exercise the shell version parser from each release workflow."""
+
+ def test_version_validation_and_release_refs(self):
+ """Accept release versions, reject shell syntax, and bind checkout/release tags."""
+ for component in ("kms", "gateway", "verifier"):
+ workflow = (ROOT / f".github/workflows/{component}-release.yml").read_text()
+ parser = textwrap.dedent(
+ workflow.split(" run: |\n", 1)[1].split("\n\n", 1)[0]
+ )
+ self.assertIn("tag_name: ${{ env.RELEASE_TAG }}", workflow)
+ self.assertIn("target_commitish: ${{ env.GIT_REV }}", workflow)
+ self.assertIn("&& env.RELEASE_TAG || github.sha", workflow)
+ for event in ("push", "workflow_dispatch"):
+ for version in (
+ "1.0-rc1",
+ "",
+ "bad;echo injected",
+ "bad\nNAME=value",
+ "x" * 129,
+ ):
+ with self.subTest(
+ component=component, event=event, version=version
+ ):
+ with tempfile.NamedTemporaryFile() as output:
+ result = subprocess.run(
+ ["bash", "-euc", parser],
+ capture_output=True,
+ text=True,
+ env={
+ **os.environ,
+ "GITHUB_EVENT_NAME": event,
+ "GITHUB_REF": f"refs/tags/{component}-v{version}"
+ if event == "push"
+ else "refs/heads/next",
+ "DISPATCH_VERSION": version,
+ "GITHUB_ENV": output.name,
+ },
+ )
+ self.assertEqual(
+ result.returncode == 0, version == "1.0-rc1"
+ )
+ if result.returncode == 0:
+ self.assertIn(
+ f"RELEASE_TAG={component}-v{version}\n",
+ Path(output.name).read_text(),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dstack/gateway/README.md b/dstack/gateway/README.md
new file mode 100644
index 000000000..af79ee75f
--- /dev/null
+++ b/dstack/gateway/README.md
@@ -0,0 +1,43 @@
+# dstack-gateway
+
+The reverse proxy that gives deployed dstack apps zero-trust network access. It
+terminates TLS, provisions certificates over ACME DNS-01, and routes traffic to
+CVMs across a WireGuard mesh it manages itself.
+
+## Ingress mapping
+
+Apps are addressed as `[-[][s|g]].`:
+
+| Form | Behaviour |
+| --- | --- |
+| `.` | TLS terminated at the gateway, forwarded as TCP |
+| `-s.` | TLS passthrough to the app |
+| `-g.` | TLS terminated, forwarded as HTTP/2 (gRPC) |
+
+`` is the app ID or the instance ID; `` defaults to 80.
+
+## Configuration
+
+`gateway.toml` holds the base domain, the certificate and WireGuard settings,
+and the admin API credentials. Operator-facing admin RPCs are served on a
+separate listener behind the shared HTTP authenticator, the same way the KMS and
+VMM expose theirs.
+
+## Running
+
+Build and run from the workspace root:
+
+```bash
+cargo build --release -p dstack-gateway
+sudo ./target/release/dstack-gateway -c gateway.toml
+```
+
+The gateway needs `CAP_NET_ADMIN` to manage its WireGuard interface, hence
+`sudo`.
+
+## Further reading
+
+- [Cluster deployment](docs/cluster-deployment.md) — running a replicated gateway
+- [Builder image](dstack-app/builder/README.md) — reproducible container image
+- [Deployment guide](../../docs/deployment.md)
+- [Security guide](../../docs/security-guide/security-guide.md)
diff --git a/dstack/gateway/dstack-app/builder/Dockerfile b/dstack/gateway/dstack-app/builder/Dockerfile
index e91b00486..42a78560f 100644
--- a/dstack/gateway/dstack-app/builder/Dockerfile
+++ b/dstack/gateway/dstack-app/builder/Dockerfile
@@ -41,6 +41,11 @@ RUN ./pin-packages.sh ./pinned-packages.txt && \
&& rm -rf /var/lib/apt/lists/* /var/log/* /var/cache/ldconfig/aux-cache
COPY --from=gateway-builder /build/repo/dstack/target/x86_64-unknown-linux-musl/release/dstack-gateway /usr/local/bin/dstack-gateway
COPY --from=gateway-builder /build/.GIT_REV /etc/
+# The OCI metadata is generated once by build-lib.sh and injected here, as
+# the config labels and as the manifest annotations, so the three views of
+# the image can never disagree.
+ARG IMAGE_METADATA
+RUN mkdir -p /etc/dstack-gateway && printf '%s\n' "${IMAGE_METADATA}" > /etc/dstack-gateway/build-info
WORKDIR /app
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
diff --git a/dstack/gateway/dstack-app/builder/README.md b/dstack/gateway/dstack-app/builder/README.md
index a49a139e0..18e074452 100644
--- a/dstack/gateway/dstack-app/builder/README.md
+++ b/dstack/gateway/dstack-app/builder/README.md
@@ -1,54 +1,88 @@
-# dstack KMS Builder
+# dstack Gateway Builder
-This directory contains the necessary files to build and run the dstack-kms Docker image for development.
+This directory contains the files needed to build the dstack-gateway Docker
+image reproducibly.
## Overview
-The builder creates a Docker image that includes:
-- The dstack-kms service compiled from Rust source code
+The builder produces a Docker image containing the dstack-gateway service
+compiled from Rust source. The build pins its base images by digest, pins the
+Debian package set (`shared/pinned-packages.txt`), and normalizes timestamps, so
+the same revision always yields the same image.
## Prerequisites
-- Docker with BuildKit support (v20.10.0+)
+- Docker with Buildx v0.13.0+ (the script creates a BuildKit v0.20.2 builder)
- Git
## Building the Image
-To build the KMS Docker image, use the provided `build-image.sh` script:
-
```bash
-./build-image.sh [:]
+./build-image.sh [:]...
```
For example:
+
```bash
-./build-image.sh kvin/kms
+./build-image.sh my-org/dstack-gateway:dev
```
-## Running the Built Image
+Optional environment variables:
+
+| Variable | Purpose |
+| --- | --- |
+| `GIT_REV` | Revision to build (default `HEAD`) |
+| `IMAGE_VERSION` | Version recorded in the image metadata (default `dev`) |
+| `IMAGE_SOURCE_URL` | Repository URL recorded in the image metadata |
+| `NO_CACHE` | Set to any value to build without the layer cache |
+| `OCI_TAR` | Also write an OCI archive here, for digest comparison |
+| `METADATA_FILE` | Write the validated OCI manifest digest and build metadata here |
+| `PUSH` | Set to any value to push the tags instead of only loading them |
+
+Publication and OCI export happen only after both package lists pass validation.
+`NO_CACHE` applies to the validation builds; export then reuses their cached result.
+Manual release workflows require an existing component release tag and check out
+that tag, rather than building the branch selected in the workflow UI.
+
+## Reproducing a released image
+
+Release CI runs this same script, so a published image can be rebuilt and
+checked digest-for-digest. Use a clean checkout of the release commit so the
+Dockerfile, package lists, shared scripts and copied files also match the release.
+From the repository root (fetch the tag first if it is not available locally):
+
+```bash
+git switch --detach "gateway-v0.6.0^{commit}"
+cd dstack/gateway/dstack-app/builder
+
+GIT_REV=HEAD \
+IMAGE_VERSION=0.6.0 \
+IMAGE_SOURCE_URL=https://github.com/Dstack-TEE/dstack \
+OCI_TAR=/tmp/gateway.oci.tar \
+ ./build-image.sh dstacktee/dstack-gateway:0.6.0
-### Using Docker Compose
-
-The easiest way to run the KMS service is using the provided `docker-compose.yaml`:
-
-```yaml
-services:
- kms:
- image: kvin/kms
- ports:
- - "8003:8000"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./kms:/kms
- environment:
- - IMAGE_DOWNLOAD_URL=${IMAGE_DOWNLOAD_URL:-http://localhost:8001/mr_{OS_IMAGE_HASH}.tar.gz}
- - AUTH_TYPE=dev
- - DEV_DOMAIN=kms.1022.dstack.org
- - QUOTE_ENABLED=false
+python3 -c 'import json,tarfile;t=tarfile.open("/tmp/gateway.oci.tar");print(json.load(t.extractfile("index.json"))["manifests"][0]["digest"])'
```
-To start the service:
+`IMAGE_VERSION` is part of the image metadata, so it must match the release for
+the digests to match. The printed digest is what the registry reports for
+`dstacktee/dstack-gateway:0.6.0`; compare it with:
```bash
-docker-compose up
+docker buildx imagetools inspect dstacktee/dstack-gateway:0.6.0 --format '{{.Manifest.Digest}}'
```
+
+## Image metadata
+
+The image carries its provenance as OCI metadata in three places, all generated
+from one definition in `dstack/build/shared/build-lib.sh`:
+
+- config labels — `docker inspect -f '{{json .Config.Labels}}' `
+- manifest annotations — `docker buildx imagetools inspect `
+- `/etc/dstack-gateway/build-info` inside the image, readable from within the CVM
+
+## Running the Built Image
+
+The gateway is normally deployed as a dstack app; see
+[`../deploy-to-vmm.sh`](../deploy-to-vmm.sh) and the
+[gateway README](../../README.md).
diff --git a/dstack/gateway/dstack-app/builder/build-image.sh b/dstack/gateway/dstack-app/builder/build-image.sh
index 3d86546f1..1b1992c57 100755
--- a/dstack/gateway/dstack-app/builder/build-image.sh
+++ b/dstack/gateway/dstack-app/builder/build-image.sh
@@ -11,18 +11,20 @@ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)
CONTEXT_DIR="$SCRIPT_DIR"
SHARED_DIR="$SCRIPT_DIR/shared"
DOCKERFILE="$SCRIPT_DIR/Dockerfile"
+export CONTEXT_DIR DOCKERFILE
+# shellcheck source=/dev/null
source "$REPO_ROOT/dstack/build/shared/build-lib.sh"
-NAME=${1:-}
-if [ -z "$NAME" ]; then
- echo "Usage: $0 [:]" >&2
+if [ "$#" -eq 0 ]; then
+ echo "Usage: $0 [:]..." >&2
exit 1
fi
+TAGS=$(printf '%s\n' "$@")
NO_CACHE=${NO_CACHE:-}
GIT_REV=${GIT_REV:-HEAD}
-GIT_REV=$(git -C "$REPO_ROOT" rev-parse "$GIT_REV")
+GIT_REV=$(git -C "$REPO_ROOT" rev-parse --verify "${GIT_REV}^{commit}")
DSTACK_SRC_URL=${DSTACK_SRC_URL:-https://github.com/Dstack-TEE/dstack.git}
ensure_buildkit
@@ -30,7 +32,10 @@ ensure_buildkit
touch "$SHARED_DIR/builder-pinned-packages.txt"
touch "$SHARED_DIR/pinned-packages.txt"
-docker_build "$NAME" "" "$SHARED_DIR/pinned-packages.txt"
-docker_build "gateway-builder-temp" "gateway-builder" "$SHARED_DIR/builder-pinned-packages.txt"
+METADATA=$(image_metadata \
+ "dstack-gateway" \
+ "Gateway service for dstack confidential applications" \
+ "dstack/gateway" \
+ "dstack/gateway/README.md")
-check_clean_tree "$SHARED_DIR"
+build_component "$TAGS" "gateway-builder-temp" "gateway-builder" "$SHARED_DIR" "$METADATA"
diff --git a/dstack/kms/dstack-app/builder/Dockerfile b/dstack/kms/dstack-app/builder/Dockerfile
index 24dd7bce4..91d9e0a04 100644
--- a/dstack/kms/dstack-app/builder/Dockerfile
+++ b/dstack/kms/dstack-app/builder/Dockerfile
@@ -42,4 +42,9 @@ RUN ./pin-packages.sh ./pinned-packages.txt && \
WORKDIR /
COPY --from=kms-builder /build/repo/dstack/target/x86_64-unknown-linux-musl/release/dstack-kms /usr/local/bin/dstack-kms
COPY --from=kms-builder /build/.GIT_REV /etc/
+# The OCI metadata is generated once by build-lib.sh and injected here, as
+# the config labels and as the manifest annotations, so the three views of
+# the image can never disagree.
+ARG IMAGE_METADATA
+RUN mkdir -p /etc/dstack-kms && printf '%s\n' "${IMAGE_METADATA}" > /etc/dstack-kms/build-info
CMD ["dstack-kms"]
diff --git a/dstack/kms/dstack-app/builder/README.md b/dstack/kms/dstack-app/builder/README.md
index 093b8b21c..923b34ebc 100644
--- a/dstack/kms/dstack-app/builder/README.md
+++ b/dstack/kms/dstack-app/builder/README.md
@@ -10,7 +10,7 @@ The builder creates a Docker image that includes:
## Prerequisites
-- Docker with BuildKit support (v20.10.0+)
+- Docker with Buildx v0.13.0+ (the script creates a BuildKit v0.20.2 builder)
- Git
## Building the Image
@@ -18,7 +18,7 @@ The builder creates a Docker image that includes:
To build the KMS Docker image, use the provided `build-image.sh` script:
```bash
-./build-image.sh [:]
+./build-image.sh [:]...
```
For example:
@@ -26,6 +26,63 @@ For example:
./build-image.sh kvin/kms
```
+Optional environment variables:
+
+| Variable | Purpose |
+| --- | --- |
+| `GIT_REV` | Revision to build (default `HEAD`) |
+| `IMAGE_VERSION` | Version recorded in the image metadata (default `dev`) |
+| `IMAGE_SOURCE_URL` | Repository URL recorded in the image metadata |
+| `NO_CACHE` | Set to any value to build without the layer cache |
+| `OCI_TAR` | Also write an OCI archive here, for digest comparison |
+| `METADATA_FILE` | Write the validated OCI manifest digest and build metadata here |
+| `PUSH` | Set to any value to push the tags instead of only loading them |
+
+Publication and OCI export happen only after both package lists pass validation.
+`NO_CACHE` applies to the validation builds; export then reuses their cached result.
+Manual release workflows require an existing component release tag and check out
+that tag, rather than building the branch selected in the workflow UI.
+
+## Reproducing a released image
+
+Release CI runs this same script, so a published image can be rebuilt and
+checked digest-for-digest. Use a clean checkout of the release commit so the
+Dockerfile, package lists, shared scripts and copied files also match the release.
+From the repository root (fetch the tag first if it is not available locally):
+
+```bash
+git switch --detach "kms-v0.6.0^{commit}"
+cd dstack/kms/dstack-app/builder
+
+GIT_REV=HEAD \
+IMAGE_VERSION=0.6.0 \
+IMAGE_SOURCE_URL=https://github.com/Dstack-TEE/dstack \
+OCI_TAR=/tmp/kms.oci.tar \
+ ./build-image.sh dstacktee/dstack-kms:0.6.0
+
+python3 -c 'import json,tarfile;t=tarfile.open("/tmp/kms.oci.tar");print(json.load(t.extractfile("index.json"))["manifests"][0]["digest"])'
+```
+
+`IMAGE_VERSION` is part of the image metadata, so it must match the release for
+the digests to match. The printed digest is what the registry reports for
+`dstacktee/dstack-kms:0.6.0`; compare it with:
+
+```bash
+docker buildx imagetools inspect dstacktee/dstack-kms:0.6.0 --format '{{.Manifest.Digest}}'
+```
+
+This is the digest that `deploy-to-vmm.sh` pins in `KMS_IMAGE`, and that in turn
+feeds the compose hash registered on chain.
+
+## Image metadata
+
+The image carries its provenance as OCI metadata in three places, all generated
+from one definition in `dstack/build/shared/build-lib.sh`:
+
+- config labels — `docker inspect -f '{{json .Config.Labels}}' `
+- manifest annotations — `docker buildx imagetools inspect `
+- `/etc/dstack-kms/build-info` inside the image, readable from within the CVM
+
## Running the Built Image
### Using Docker Compose
diff --git a/dstack/kms/dstack-app/builder/build-image.sh b/dstack/kms/dstack-app/builder/build-image.sh
index f126fe0ea..c04c54f1d 100755
--- a/dstack/kms/dstack-app/builder/build-image.sh
+++ b/dstack/kms/dstack-app/builder/build-image.sh
@@ -16,15 +16,15 @@ export CONTEXT_DIR DOCKERFILE
# shellcheck source=/dev/null
source "$REPO_ROOT/dstack/build/shared/build-lib.sh"
-NAME=${1:-}
-if [ -z "$NAME" ]; then
- echo "Usage: $0 [:]" >&2
+if [ "$#" -eq 0 ]; then
+ echo "Usage: $0 [:]..." >&2
exit 1
fi
+TAGS=$(printf '%s\n' "$@")
NO_CACHE=${NO_CACHE:-}
GIT_REV=${GIT_REV:-HEAD}
-GIT_REV=$(git -C "$REPO_ROOT" rev-parse "$GIT_REV")
+GIT_REV=$(git -C "$REPO_ROOT" rev-parse --verify "${GIT_REV}^{commit}")
DSTACK_SRC_URL=${DSTACK_SRC_URL:-https://github.com/Dstack-TEE/dstack.git}
ensure_buildkit
@@ -32,7 +32,10 @@ ensure_buildkit
touch "$SHARED_DIR/builder-pinned-packages.txt"
touch "$SHARED_DIR/pinned-packages.txt"
-docker_build "$NAME" "" "$SHARED_DIR/pinned-packages.txt"
-docker_build "kms-builder-temp" "kms-builder" "$SHARED_DIR/builder-pinned-packages.txt"
+METADATA=$(image_metadata \
+ "dstack-kms" \
+ "Key management service for dstack confidential applications" \
+ "dstack/kms" \
+ "dstack/kms/README.md")
-check_clean_tree "$SHARED_DIR"
+build_component "$TAGS" "kms-builder-temp" "kms-builder" "$SHARED_DIR" "$METADATA"
diff --git a/dstack/verifier/builder/Dockerfile b/dstack/verifier/builder/Dockerfile
index 5b9245200..58f36bd95 100644
--- a/dstack/verifier/builder/Dockerfile
+++ b/dstack/verifier/builder/Dockerfile
@@ -42,6 +42,11 @@ RUN ./pin-packages.sh ./pinned-packages.txt && \
&& rm -rf /var/lib/apt/lists/* /var/log/* /var/cache/ldconfig/aux-cache
COPY --from=verifier-builder /build/repo/dstack/target/x86_64-unknown-linux-musl/release/dstack-verifier /usr/local/bin/dstack-verifier
COPY --from=verifier-builder /build/.GIT_REV /etc/
+# The OCI metadata is generated once by build-lib.sh and injected here, as
+# the config labels and as the manifest annotations, so the three views of
+# the image can never disagree.
+ARG IMAGE_METADATA
+RUN mkdir -p /etc/dstack-verifier && printf '%s\n' "${IMAGE_METADATA}" > /etc/dstack-verifier/build-info
RUN mkdir -p /etc/dstack
COPY dstack-verifier.toml /etc/dstack/dstack-verifier.toml
WORKDIR /var/lib/dstack-verifier
diff --git a/dstack/verifier/builder/README.md b/dstack/verifier/builder/README.md
new file mode 100644
index 000000000..9e409e925
--- /dev/null
+++ b/dstack/verifier/builder/README.md
@@ -0,0 +1,89 @@
+# dstack Verifier Builder
+
+This directory contains the files needed to build the dstack-verifier Docker
+image reproducibly.
+
+## Overview
+
+The builder produces a Docker image containing the dstack-verifier service
+compiled from Rust source. The build pins its base images by digest, pins the
+Debian package set (`shared/pinned-packages.txt`), and normalizes timestamps, so
+the same revision always yields the same image.
+
+## Prerequisites
+
+- Docker with Buildx v0.13.0+ (the script creates a BuildKit v0.20.2 builder)
+- Git
+
+## Building the Image
+
+```bash
+./build-image.sh [:]...
+```
+
+For example:
+
+```bash
+./build-image.sh my-org/dstack-verifier:dev
+```
+
+Optional environment variables:
+
+| Variable | Purpose |
+| --- | --- |
+| `GIT_REV` | Revision to build (default `HEAD`) |
+| `IMAGE_VERSION` | Version recorded in the image metadata (default `dev`) |
+| `IMAGE_SOURCE_URL` | Repository URL recorded in the image metadata |
+| `NO_CACHE` | Set to any value to build without the layer cache |
+| `OCI_TAR` | Also write an OCI archive here, for digest comparison |
+| `METADATA_FILE` | Write the validated OCI manifest digest and build metadata here |
+| `PUSH` | Set to any value to push the tags instead of only loading them |
+
+Publication and OCI export happen only after both package lists pass validation.
+`NO_CACHE` applies to the validation builds; export then reuses their cached result.
+Manual release workflows require an existing component release tag and check out
+that tag, rather than building the branch selected in the workflow UI.
+
+## Reproducing a released image
+
+Release CI runs this same script, so a published image can be rebuilt and
+checked digest-for-digest. Use a clean checkout of the release commit so the
+Dockerfile, package lists, shared scripts and copied files also match the release.
+From the repository root (fetch the tag first if it is not available locally):
+
+```bash
+git switch --detach "verifier-v0.6.0^{commit}"
+cd dstack/verifier/builder
+
+GIT_REV=HEAD \
+IMAGE_VERSION=0.6.0 \
+IMAGE_SOURCE_URL=https://github.com/Dstack-TEE/dstack \
+OCI_TAR=/tmp/verifier.oci.tar \
+ ./build-image.sh dstacktee/dstack-verifier:0.6.0
+
+python3 -c 'import json,tarfile;t=tarfile.open("/tmp/verifier.oci.tar");print(json.load(t.extractfile("index.json"))["manifests"][0]["digest"])'
+```
+
+`IMAGE_VERSION` is part of the image metadata, so it must match the release for
+the digests to match. The printed digest is what the registry reports for
+`dstacktee/dstack-verifier:0.6.0`; compare it with:
+
+```bash
+docker buildx imagetools inspect dstacktee/dstack-verifier:0.6.0 --format '{{.Manifest.Digest}}'
+```
+
+Note that the release also publishes a `:latest` tag pointing at the same
+digest.
+
+## Image metadata
+
+The image carries its provenance as OCI metadata in three places, all generated
+from one definition in `dstack/build/shared/build-lib.sh`:
+
+- config labels — `docker inspect -f '{{json .Config.Labels}}' `
+- manifest annotations — `docker buildx imagetools inspect `
+- `/etc/dstack-verifier/build-info` inside the image, readable from within the CVM
+
+## Running the Built Image
+
+See the [verifier README](../README.md) for configuration and the HTTP API.
diff --git a/dstack/verifier/builder/build-image.sh b/dstack/verifier/builder/build-image.sh
index d9db1288e..3530a80b0 100755
--- a/dstack/verifier/builder/build-image.sh
+++ b/dstack/verifier/builder/build-image.sh
@@ -16,15 +16,15 @@ export CONTEXT_DIR DOCKERFILE
# shellcheck source=/dev/null
source "$REPO_ROOT/dstack/build/shared/build-lib.sh"
-NAME=${1:-}
-if [ -z "$NAME" ]; then
- echo "Usage: $0 [:]" >&2
+if [ "$#" -eq 0 ]; then
+ echo "Usage: $0 [:]..." >&2
exit 1
fi
+TAGS=$(printf '%s\n' "$@")
NO_CACHE=${NO_CACHE:-}
GIT_REV=${GIT_REV:-HEAD}
-GIT_REV=$(git -C "$REPO_ROOT" rev-parse "$GIT_REV")
+GIT_REV=$(git -C "$REPO_ROOT" rev-parse --verify "${GIT_REV}^{commit}")
DSTACK_SRC_URL=${DSTACK_SRC_URL:-https://github.com/Dstack-TEE/dstack.git}
ensure_buildkit
@@ -33,7 +33,10 @@ mkdir -p "$SHARED_DIR"
touch "$SHARED_DIR/builder-pinned-packages.txt"
touch "$SHARED_DIR/pinned-packages.txt"
-docker_build "$NAME" "" "$SHARED_DIR/pinned-packages.txt"
-docker_build "verifier-builder-temp" "verifier-builder" "$SHARED_DIR/builder-pinned-packages.txt"
+METADATA=$(image_metadata \
+ "dstack-verifier" \
+ "Remote attestation verification service for dstack" \
+ "dstack/verifier" \
+ "dstack/verifier/README.md")
-check_clean_tree "$SHARED_DIR"
+build_component "$TAGS" "verifier-builder-temp" "verifier-builder" "$SHARED_DIR" "$METADATA"