|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Publish the image to GHCR by hand, including `latest`. |
| 3 | +# |
| 4 | +# The tag push is the normal path: tagging `v1.2.0` makes |
| 5 | +# .github/workflows/docker.yml build both architectures, smoke-test the exact |
| 6 | +# image it is about to publish, and push `1.2.0`, `1.2`, `1`, `latest` and a |
| 7 | +# short sha. Use this script when that path is not available: the workflow is |
| 8 | +# failing, the runner cannot reach the registry, or an image has to go out |
| 9 | +# from a laptop. |
| 10 | +# |
| 11 | +# scripts/publish-image.sh # version from pyproject.toml |
| 12 | +# scripts/publish-image.sh 1.2.0 # or say it, and it is checked |
| 13 | +# DRY_RUN=1 scripts/publish-image.sh # build and smoke-test, push nothing |
| 14 | +# |
| 15 | +# It does what the workflow does, in the same order, because the point of the |
| 16 | +# workflow is that nothing reaches `latest` without passing a generate, a load |
| 17 | +# and a verify first. It refuses to publish a version that does not match |
| 18 | +# pyproject.toml, for the same reason python-publish.yml does: the image and |
| 19 | +# the PyPI release must not drift. |
| 20 | +set -euo pipefail |
| 21 | + |
| 22 | +# MSYS and git-bash rewrite anything that looks like a unix path when they |
| 23 | +# spawn a native binary, which mangles the *container* side of a mount: |
| 24 | +# `--out /data` arrives as `--out C:/Program Files/Git/data`, the container |
| 25 | +# writes a directory literally called `C:`, and the host sees no dataset. The |
| 26 | +# host side is converted deliberately, by host_path below. |
| 27 | +export MSYS_NO_PATHCONV=1 |
| 28 | +export MSYS2_ARG_CONV_EXCL='*' |
| 29 | + |
| 30 | +IMAGE="${IMAGE:-ghcr.io/graphgeeks-lab/graphfaker}" |
| 31 | +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" |
| 32 | +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 33 | +cd "$ROOT" |
| 34 | + |
| 35 | +say() { printf '\n== %s\n' "$*"; } |
| 36 | +die() { printf '\nerror: %s\n' "$*" >&2; exit 1; } |
| 37 | + |
| 38 | +# A bind mount needs a path the host's docker understands. Under MSYS or |
| 39 | +# git-bash on Windows, $PWD is a virtual path: docker accepts it, mounts |
| 40 | +# something inside its own VM, and the container writes a bank the host cannot |
| 41 | +# see. The smoke test would then pass having proved nothing about the one thing |
| 42 | +# a user cares about, which is the output landing in their directory. |
| 43 | +host_path() { |
| 44 | + if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1"; else printf '%s' "$1"; fi |
| 45 | +} |
| 46 | + |
| 47 | +PROJECT_VERSION="$(sed -nE 's/^version = "(.*)"/\1/p' pyproject.toml | head -1)" |
| 48 | +[ -n "$PROJECT_VERSION" ] || die "no version in pyproject.toml" |
| 49 | +VERSION="${1:-$PROJECT_VERSION}" |
| 50 | + |
| 51 | +if [ "$VERSION" != "$PROJECT_VERSION" ]; then |
| 52 | + die "asked to publish $VERSION but pyproject.toml says $PROJECT_VERSION. |
| 53 | +Bump the version and HISTORY.rst first; an image whose version does not match |
| 54 | +the release it claims to be is worse than no image." |
| 55 | +fi |
| 56 | + |
| 57 | +MAJOR="${VERSION%%.*}" |
| 58 | +MINOR="${VERSION%.*}" |
| 59 | +case "$VERSION" in |
| 60 | + *[!0-9.]*) die "'$VERSION' is not a release version; this script does not publish pre-releases to latest" ;; |
| 61 | +esac |
| 62 | + |
| 63 | +say "Publishing $IMAGE $VERSION as $VERSION, $MINOR, $MAJOR and latest" |
| 64 | +command -v docker >/dev/null || die "docker is not on PATH" |
| 65 | +docker buildx version >/dev/null 2>&1 || die "docker buildx is not available" |
| 66 | + |
| 67 | +# arm64 is emulated when the host is amd64, so QEMU has to be registered |
| 68 | +# before buildx can target it. Harmless when it is already there. |
| 69 | +say "Registering QEMU for the other architecture" |
| 70 | +docker run --privileged --rm tonistiigi/binfmt --install all >/dev/null 2>&1 \ |
| 71 | + || echo " (could not register QEMU; a cross build may fail)" |
| 72 | + |
| 73 | +# Built to the local daemon first, so the smoke test runs against the exact |
| 74 | +# image that gets pushed rather than a rebuild of it. |
| 75 | +say "Building for this machine, to smoke-test" |
| 76 | +docker buildx build --load -t "graphfaker:publish" . |
| 77 | + |
| 78 | +WORK="$(mktemp -d)" |
| 79 | +# The bind mount lives under the checkout rather than in a temp directory, |
| 80 | +# because that is a path both the host and docker agree about on every |
| 81 | +# platform. Removed on the way out, including on failure. |
| 82 | +SMOKE="$ROOT/.publish-smoke" |
| 83 | +rm -rf "$SMOKE" || die "could not clear $SMOKE; remove it and try again" |
| 84 | +trap 'rm -rf "$WORK" "$SMOKE" 2>/dev/null || true' EXIT |
| 85 | + |
| 86 | +# Output is captured to a file before it is searched. Piping `docker run` into |
| 87 | +# `grep -q` makes grep exit on its first match, docker take SIGPIPE, and |
| 88 | +# `pipefail` fail the whole thing: "write /dev/stdout: The pipe is being |
| 89 | +# closed", reported as a missing fraud domain. The workflow has the same note |
| 90 | +# over the same two commands. |
| 91 | +say "The CLI is there and reports the version in pyproject.toml" |
| 92 | +docker run --rm graphfaker:publish --version > "$WORK/version.txt" |
| 93 | +grep -qx "graphfaker $VERSION" "$WORK/version.txt" \ |
| 94 | + || { cat "$WORK/version.txt"; die "the image reports a different version"; } |
| 95 | +docker run --rm graphfaker:publish domains > "$WORK/domains.txt" |
| 96 | +grep -q fraud "$WORK/domains.txt" \ |
| 97 | + || die "the fraud domain did not ship in the image" |
| 98 | + |
| 99 | +say "It runs as a non-root user" |
| 100 | +[ "$(docker run --rm --entrypoint id graphfaker:publish -u)" != "0" ] \ |
| 101 | + || die "the image runs as root" |
| 102 | + |
| 103 | +say "It generates a bank into a volume, loads it and verifies it" |
| 104 | +mkdir -p "$SMOKE/bank" && chmod 777 "$SMOKE/bank" |
| 105 | +MOUNT="$(host_path "$SMOKE/bank")" |
| 106 | +docker run --rm -v "$MOUNT:/data" graphfaker:publish \ |
| 107 | + fraud --scale 0.001 --seed 42 --out /data --no-report |
| 108 | +[ -f "$SMOKE/bank/manifest.json" ] \ |
| 109 | + || die "the container wrote no manifest.json the host can see. |
| 110 | +Mounted $MOUNT; if that is not a path your docker resolves to this directory, |
| 111 | +the bind mount went somewhere else." |
| 112 | +docker run --rm -v "$MOUNT:/data" graphfaker:publish load duckdb /data --no-verify |
| 113 | +docker run --rm -v "$MOUNT:/data" graphfaker:publish verify duckdb /data |
| 114 | + |
| 115 | +if [ "${DRY_RUN:-}" = "1" ]; then |
| 116 | + say "DRY_RUN=1, so nothing was pushed. The image passed every check." |
| 117 | + exit 0 |
| 118 | +fi |
| 119 | + |
| 120 | +say "Logging in to ghcr.io" |
| 121 | +if [ -n "${GITHUB_TOKEN:-}" ]; then |
| 122 | + echo "$GITHUB_TOKEN" | docker login ghcr.io -u "${GITHUB_ACTOR:-$USER}" --password-stdin |
| 123 | +elif command -v gh >/dev/null; then |
| 124 | + gh auth token | docker login ghcr.io -u "$(gh api user --jq .login)" --password-stdin |
| 125 | +else |
| 126 | + die "set GITHUB_TOKEN (a classic token with write:packages) or install gh" |
| 127 | +fi |
| 128 | + |
| 129 | +say "Building both architectures and pushing" |
| 130 | +docker buildx build \ |
| 131 | + --platform "$PLATFORMS" \ |
| 132 | + --tag "$IMAGE:$VERSION" \ |
| 133 | + --tag "$IMAGE:$MINOR" \ |
| 134 | + --tag "$IMAGE:$MAJOR" \ |
| 135 | + --tag "$IMAGE:latest" \ |
| 136 | + --push . |
| 137 | + |
| 138 | +say "What is on the registry now" |
| 139 | +docker manifest inspect "$IMAGE:latest" > "$WORK/manifest.json" \ |
| 140 | + || die "latest was pushed but its manifest cannot be read back" |
| 141 | +grep -E '"architecture"' "$WORK/manifest.json" \ |
| 142 | + || die "latest was pushed but its manifest lists no architecture" |
| 143 | +printf '\nPublished:\n' |
| 144 | +for tag in "$VERSION" "$MINOR" "$MAJOR" latest; do |
| 145 | + printf ' %s:%s\n' "$IMAGE" "$tag" |
| 146 | +done |
| 147 | +printf '\nPull it somewhere else to be sure:\n docker run --rm %s:latest info\n' "$IMAGE" |
0 commit comments