Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .github/scripts/report_docker_image_size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Append local and registry container image size metrics to a CI summary."""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any

BYTES_PER_MEBIBYTE = 1024 * 1024
DOCKER_IMAGE_INSPECT_COMMAND = ("docker", "image", "inspect")
DOCKER_REGISTRY_MANIFEST_COMMAND = (
"docker",
"buildx",
"imagetools",
"inspect",
"--raw",
)
SUMMARY_ENVIRONMENT_VARIABLE = "GITHUB_STEP_SUMMARY"


def run_command(*command: str) -> str:
"""Run a command and return its standard output, with actionable failures."""
try:
result = subprocess.run(command, check=True, capture_output=True, text=True)
except FileNotFoundError as error:
raise RuntimeError(f"Required command is unavailable: {command[0]}") from error
except subprocess.CalledProcessError as error:
details = error.stderr.strip() or error.stdout.strip() or "no command output"
raise RuntimeError(
f"Command failed ({' '.join(command)}): {details}"
) from error
return result.stdout.strip()


def read_local_image_size(image: str) -> int:
"""Return Docker's unpacked image size in bytes."""
output = run_command(*DOCKER_IMAGE_INSPECT_COMMAND, "--format", "{{.Size}}", image)
try:
size = int(output)
except ValueError as error:
raise RuntimeError(
f"Docker returned an invalid image size for {image!r}: {output!r}"
) from error
if size < 0:
raise RuntimeError(
f"Docker returned a negative image size for {image!r}: {size}"
)
return size


def read_registry_layer_size(image: str) -> int:
"""Return the sum of compressed OCI layer sizes for a platform image manifest."""
try:
manifest: dict[str, Any] = json.loads(
run_command(*DOCKER_REGISTRY_MANIFEST_COMMAND, image)
)
except json.JSONDecodeError as error:
raise RuntimeError(
f"Registry returned invalid manifest JSON for {image!r}"
) from error

layers = manifest.get("layers")
if not isinstance(layers, list):
raise TypeError(
f"Registry manifest for {image!r} does not contain platform layers"
)

total_size = 0
for layer in layers:
try:
layer_size = layer["size"]
except (KeyError, TypeError) as error:
raise RuntimeError(
f"Registry manifest for {image!r} has invalid layer size data"
) from error
if (
not isinstance(layer_size, int)
or isinstance(layer_size, bool)
or layer_size < 0
):
raise RuntimeError(
f"Registry manifest for {image!r} has invalid layer size data"
)
total_size += layer_size
return total_size


def format_size(size_in_bytes: int) -> str:
"""Format a byte count for the workflow summary without losing precision."""
return f"{size_in_bytes / BYTES_PER_MEBIBYTE:.2f} MiB ({size_in_bytes:,} bytes)"


def write_summary(
platform: str, image: str, local_size: int, registry_size: int | None
) -> None:
"""Print and append the size report, when running in GitHub Actions."""
rows = [("Unpacked image (Docker)", format_size(local_size))]
if registry_size is not None:
rows.append(("Compressed OCI layers (registry)", format_size(registry_size)))

lines = [
"## Container Image Size",
"",
f"Platform: `{platform}`",
"",
"| Metric | Size |",
"| --- | ---: |",
*(f"| {label} | {value} |" for label, value in rows),
"",
f"Image: `{image}`",
"",
]
report = "\n".join(lines)
print(report)

summary_path = os.environ.get(SUMMARY_ENVIRONMENT_VARIABLE)
if summary_path:
with Path(summary_path).open("a", encoding="utf-8") as summary_file:
summary_file.write(report)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", required=True, help="Local Docker image reference")
parser.add_argument("--platform", required=True, help="OCI platform identifier")
parser.add_argument(
"--registry-image",
help="Pushed platform-manifest reference used to report compressed OCI layer size",
)
arguments = parser.parse_args()

try:
local_size = read_local_image_size(arguments.image)
registry_size = (
read_registry_layer_size(arguments.registry_image)
if arguments.registry_image
else None
)
write_summary(arguments.platform, arguments.image, local_size, registry_size)
except (RuntimeError, TypeError) as error:
print(f"Unable to report container image size: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
9 changes: 9 additions & 0 deletions .github/workflows/docker-image-preview-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,15 @@ jobs:
shell: bash
run: docker pull --platform ${{ matrix.docker_platform }} "${{ needs.prepare.outputs.staging_image_name }}@${{ steps.build.outputs.digest }}"

- name: Report preview platform image size
shell: bash
run: |
image_ref="${{ needs.prepare.outputs.staging_image_name }}@${{ steps.build.outputs.digest }}"
python3 .github/scripts/report_docker_image_size.py \
--image "$image_ref" \
--platform "${{ matrix.docker_platform }}" \
--registry-image "$image_ref"

- name: Start preview platform validation container
shell: bash
run: |
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/docker-image-security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ jobs:
cache-from: type=gha,scope=docker-image-security-scan-${{ matrix.platform_id }}
cache-to: type=gha,mode=max,scope=docker-image-security-scan-${{ matrix.platform_id }}

- name: Report security-scan image size
shell: bash
run: |
python3 .github/scripts/report_docker_image_size.py \
--image "sambee:security-scan-${{ matrix.platform_id }}" \
--platform "${{ matrix.docker_platform }}"

- name: Resolve Trivy container image
id: trivy-image
shell: bash
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/docker-image-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ on:
- 'scripts/verify-python-runtime-image.py'
- 'scripts/verify-node-runtime.py'
- 'scripts/verify-archive-single-worker.py'
- '.github/scripts/report_docker_image_size.py'
- 'Dockerfile'
- '.dockerignore'
- 'VERSION'
Expand Down Expand Up @@ -62,6 +63,7 @@ on:
- 'scripts/verify-python-runtime-image.py'
- 'scripts/verify-node-runtime.py'
- 'scripts/verify-archive-single-worker.py'
- '.github/scripts/report_docker_image_size.py'
- 'Dockerfile'
- '.dockerignore'
- 'VERSION'
Expand Down Expand Up @@ -146,6 +148,13 @@ jobs:
cache-from: type=gha,scope=docker-image-validate-${{ matrix.platform_id }}
cache-to: type=gha,mode=max,scope=docker-image-validate-${{ matrix.platform_id }}

- name: Report validation image size
shell: bash
run: |
python3 .github/scripts/report_docker_image_size.py \
--image "sambee:validation-${{ matrix.platform_id }}" \
--platform "${{ matrix.docker_platform }}"

- name: Export attested validation OCI layout
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ USER sambee
# Expose port
EXPOSE 8000

# Health check (wget is installed via install-system-deps script)
# Health check uses Python's standard library, so no separate HTTP client is needed.
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8000/api/health >/dev/null || exit 1
CMD python -c "from urllib.request import urlopen; urlopen('http://localhost:8000/api/health', timeout=5).close()" || exit 1

# Block deployment before migrations when legacy archive operation state remains.
CMD ["sh", "-c", "/app/scripts/preflight-archive-v2-cutover && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 --no-proxy-headers"]
4 changes: 0 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# TODO

## Text/Markdown viewers and editors

- I cannot scroll beneath the last line to move that further up the screen. That means that longer texts end exactly on the last visible line of the viewport. It would be nice to move the up a bit.

## Misc. commands

- Add a command to open the current file list location in a new browser tab (which keyboard shortcut to assign? Ctrl+(Shift)+Tab is needed by the browser)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.26
1.0.27
4 changes: 2 additions & 2 deletions companion/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion companion/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sambee-companion",
"version": "1.0.26",
"version": "1.0.27",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion companion/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion companion/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sambee-companion"
version = "1.0.26"
version = "1.0.27"
edition = "2021"
description = "Sambee Companion — opens files from Sambee in native desktop editors"
authors = []
Expand Down
2 changes: 1 addition & 1 deletion companion/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2-schema/refs/heads/main/tauri.conf.json.schema",
"productName": "Sambee Companion",
"identifier": "app.sambee.companion",
"version": "1.0.26",
"version": "1.0.27",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
Expand Down
Loading