diff --git a/.github/scripts/compute_image_fingerprint.py b/.github/scripts/compute_image_fingerprint.py new file mode 100644 index 0000000..bcc86d5 --- /dev/null +++ b/.github/scripts/compute_image_fingerprint.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Compute a stable fingerprint for the runtime contents of a local Docker image.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path + + +def run(*args: str) -> str: + completed = subprocess.run( + list(args), + check=True, + capture_output=True, + text=True, + ) + return completed.stdout + + +def hash_stream(fileobj) -> str: + digest = hashlib.sha256() + while True: + chunk = fileobj.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def normalized_config(image_ref: str) -> bytes: + inspect_payload = json.loads(run("docker", "image", "inspect", image_ref))[0] + config_payload = { + "architecture": inspect_payload.get("Architecture"), + "os": inspect_payload.get("Os"), + "variant": inspect_payload.get("Variant"), + "config": inspect_payload.get("Config", {}), + } + return json.dumps( + config_payload, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def normalized_tar_entries(tar_path: Path) -> list[str]: + records: list[str] = [] + with tarfile.open(tar_path, mode="r") as archive: + for member in archive.getmembers(): + path = member.name.rstrip("/") or "." + record: dict[str, object] = { + "path": path, + "mode": member.mode, + } + + if member.isdir(): + record["type"] = "dir" + elif member.isfile(): + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError(f"Unable to read file contents for {path}") + with extracted: + record["type"] = "file" + record["sha256"] = hash_stream(extracted) + elif member.issym(): + record["type"] = "symlink" + record["target"] = member.linkname + elif member.islnk(): + record["type"] = "hardlink" + record["target"] = member.linkname + elif member.ischr(): + record["type"] = "char" + record["device"] = [member.devmajor, member.devminor] + elif member.isblk(): + record["type"] = "block" + record["device"] = [member.devmajor, member.devminor] + elif member.isfifo(): + record["type"] = "fifo" + else: + record["type"] = f"other:{member.type!r}" + if member.linkname: + record["target"] = member.linkname + + records.append( + json.dumps(record, sort_keys=True, separators=(",", ":")) + ) + + records.sort() + return records + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: compute_image_fingerprint.py ", file=sys.stderr) + return 2 + + image_ref = sys.argv[1] + container_id = run("docker", "create", image_ref).strip() + + try: + with tempfile.TemporaryDirectory(prefix="clawmanager-image-export-") as temp_dir: + tar_path = Path(temp_dir) / "filesystem.tar" + subprocess.run( + ["docker", "export", "-o", str(tar_path), container_id], + check=True, + ) + + digest = hashlib.sha256() + digest.update(normalized_config(image_ref)) + digest.update(b"\n") + + for record in normalized_tar_entries(tar_path): + digest.update(record.encode("utf-8")) + digest.update(b"\n") + + print(digest.hexdigest()) + finally: + subprocess.run( + ["docker", "rm", "-f", container_id], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 316b08b..21b5945 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,30 +33,16 @@ jobs: with: fetch-depth: 0 + - name: Build candidate image + shell: bash + run: | + docker build --pull -t clawmanager:release-candidate . + - name: Compute release fingerprint shell: bash run: | - set -euo pipefail - - mapfile -t release_files < <(git ls-files -- \ - Dockerfile \ - .dockerignore \ - frontend \ - backend \ - deployments/container/start.sh \ - deployments/nginx/nginx.conf) - - mapfile -t base_images < <(awk 'toupper($1)=="FROM" { print $2 }' Dockerfile) - - { - for image in "${base_images[@]}"; do - printf 'base-image %s\n' "$image" - done - - for file in "${release_files[@]}"; do - sha256sum "$file" - done - } | sha256sum | awk '{print "IMAGE_FINGERPRINT="$1}' >> "$GITHUB_ENV" + IMAGE_FINGERPRINT="$(python3 .github/scripts/compute_image_fingerprint.py clawmanager:release-candidate)" + echo "IMAGE_FINGERPRINT=${IMAGE_FINGERPRINT}" >> "$GITHUB_ENV" - name: Read latest release fingerprint id: latest-release @@ -94,6 +80,12 @@ jobs: LAST_FINGERPRINT="${LAST_FINGERPRINT//\`/}" CURRENT_FINGERPRINT="${CURRENT_FINGERPRINT//\`/}" + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "tag_name=" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$FORCE_RELEASE" = "true" ]; then echo "should_release=true" >> "$GITHUB_OUTPUT" echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" @@ -106,12 +98,6 @@ jobs: exit 0 fi - if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "should_release=false" >> "$GITHUB_OUTPUT" - echo "tag_name=" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "should_release=true" >> "$GITHUB_OUTPUT" echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" @@ -152,35 +138,27 @@ jobs: fi echo "IMAGE_URI=ghcr.io/${REPOSITORY_OWNER_LOWER}/clawmanager" >> "$GITHUB_ENV" - - name: Compute release fingerprint - shell: bash - run: | - set -euo pipefail - - mapfile -t release_files < <(git ls-files -- \ - Dockerfile \ - .dockerignore \ - frontend \ - backend \ - deployments/container/start.sh \ - deployments/nginx/nginx.conf) - - mapfile -t base_images < <(awk 'toupper($1)=="FROM" { print $2 }' Dockerfile) - - { - for image in "${base_images[@]}"; do - printf 'base-image %s\n' "$image" - done - - for file in "${release_files[@]}"; do - sha256sum "$file" - done - } | sha256sum | awk '{print "IMAGE_FINGERPRINT="$1}' >> "$GITHUB_ENV" - - name: Checkout release tag if: github.event_name != 'push' run: git checkout "${TAG_NAME}" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build release image + shell: bash + run: | + docker build --pull \ + -t "${IMAGE_URI}:${TAG_NAME}" \ + -t "${IMAGE_URI}:latest" \ + . + + - name: Compute release fingerprint + shell: bash + run: | + IMAGE_FINGERPRINT="$(python3 .github/scripts/compute_image_fingerprint.py "${IMAGE_URI}:${TAG_NAME}")" + echo "IMAGE_FINGERPRINT=${IMAGE_FINGERPRINT}" >> "$GITHUB_ENV" + - name: Log in to GHCR uses: docker/login-action@v3 with: @@ -188,21 +166,23 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Push release image + shell: bash + run: | + docker push "${IMAGE_URI}:${TAG_NAME}" + docker push "${IMAGE_URI}:latest" - - name: Build and push release image - id: docker-build - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - push: true - tags: | - ${{ env.IMAGE_URI }}:${{ env.TAG_NAME }} - ${{ env.IMAGE_URI }}:latest + - name: Read pushed image digest + shell: bash + run: | + IMAGE_DIGEST="$(docker buildx imagetools inspect "${IMAGE_URI}:${TAG_NAME}" | sed -n 's/^Digest:[[:space:]]*//p' | head -n1)" + if [ -z "${IMAGE_DIGEST}" ]; then + echo "Failed to resolve image digest for ${IMAGE_URI}:${TAG_NAME}" >&2 + exit 1 + fi + echo "IMAGE_DIGEST=${IMAGE_DIGEST}" >> "$GITHUB_ENV" - - name: Build release highlights + - name: Build release notes shell: bash run: | cat < /tmp/release-highlights.md @@ -217,7 +197,7 @@ jobs: Image: \`${IMAGE_URI}:${TAG_NAME}\` Latest: \`${IMAGE_URI}:latest\` - Image-Digest: \`${{ steps.docker-build.outputs.digest }}\` + Image-Digest: \`${IMAGE_DIGEST}\` Build-Fingerprint: \`${IMAGE_FINGERPRINT}\` EOF diff --git a/Dockerfile b/Dockerfile index eceaab3..2173302 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ ./ -RUN CGO_ENABLED=0 GOOS=linux go build -o /out/clawreef-server ./cmd/server +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o /out/clawreef-server ./cmd/server FROM nginx:1.27-alpine