mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 00:03:42 +00:00
167 lines
6.1 KiB
Bash
Executable File
167 lines
6.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Checks that every published image can be pulled by a stranger.
|
|
#
|
|
# GHCR creates a package private and does NOT inherit the repository's
|
|
# visibility, so an image published from a public repo by a green workflow is
|
|
# still unreadable to everyone outside the org until an owner flips it by hand
|
|
# in the UI. There is no API for that flip, which means nothing in CI can fix
|
|
# it and everything in CI must at least notice it: releases v0.1.0 through
|
|
# v0.4.0 all shipped a `curl | sh` installer that could not pull a single byte,
|
|
# and every check we had passed, because each one ran authenticated (#371).
|
|
#
|
|
# So this speaks to the registry the way an anonymous `docker pull` does: an
|
|
# unauthenticated token, then the manifest. No docker, no login, no
|
|
# credentials to accidentally inherit from the runner.
|
|
#
|
|
# It prints "<service><TAB><digest>" per image on stdout, so the release can
|
|
# build images.json out of the same public view it just verified rather than
|
|
# out of a privileged one.
|
|
#
|
|
# scripts/check-images-public.sh [--prefix P] [--tag T] [--warn] [service...]
|
|
set -euo pipefail
|
|
|
|
PREFIX="ghcr.io/warmbly/warmbly"
|
|
TAG=""
|
|
WARN_ONLY=0
|
|
SERVICES=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--prefix) PREFIX=$2; shift 2 ;;
|
|
--tag) TAG=$2; shift 2 ;;
|
|
--warn) WARN_ONLY=1; shift ;;
|
|
-h|--help)
|
|
sed -n '2,20p' "$0" | sed 's|^# \{0,1\}||'
|
|
exit 0 ;;
|
|
-*) echo "unknown flag: $1" >&2; exit 2 ;;
|
|
*) SERVICES+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
# Every image the release advertises. The nine compose services, plus the cli
|
|
# image, which the release notes table tells people to pull.
|
|
if [[ ${#SERVICES[@]} -eq 0 ]]; then
|
|
SERVICES=(backend consumer worker forms updater web admin tracking realtime cli)
|
|
fi
|
|
|
|
fail() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; }
|
|
pass() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
|
|
info() { printf '%s\n' "$*" >&2; }
|
|
|
|
HOST=${PREFIX%%/*}
|
|
NAMESPACE=${PREFIX#*/}
|
|
if [[ $HOST != "ghcr.io" ]]; then
|
|
info "· $PREFIX is not on ghcr.io; the anonymous check only speaks GHCR's token flow. Skipped."
|
|
exit 0
|
|
fi
|
|
|
|
# No tag given means "whatever a fresh install would resolve to", which is the
|
|
# newest GitHub release, because that is what install.sh pins.
|
|
if [[ -z $TAG ]]; then
|
|
TAG=$(curl -fsSL -H 'Accept: application/vnd.github+json' \
|
|
https://api.github.com/repos/warmbly/warmbly/releases/latest 2>/dev/null |
|
|
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -1)
|
|
[[ -n $TAG ]] || { fail "could not resolve the newest release; pass --tag"; exit 1; }
|
|
fi
|
|
|
|
info "Checking $PREFIX/*:$TAG is pullable with no credentials"
|
|
info ""
|
|
|
|
# A manifest list is what a multi-arch pull resolves, and its digest is what
|
|
# `docker image inspect` reports as the RepoDigest, so this Accept set is what
|
|
# makes the printed digest comparable to what lands on an operator's machine.
|
|
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
|
|
|
|
# Sets CODE and DIGEST rather than printing them: the response headers carry
|
|
# both, and a command substitution would run this in a subshell and lose them.
|
|
CODE=""
|
|
DIGEST=""
|
|
probe() {
|
|
local svc=$1 token headers
|
|
CODE="000"; DIGEST=""
|
|
token=$(curl -fsSL --max-time 20 \
|
|
"https://${HOST}/token?scope=repository:${NAMESPACE}/${svc}:pull&service=${HOST}" 2>/dev/null |
|
|
sed -n 's/.*"token":"\([^"]*\)".*/\1/p') || true
|
|
# No token at all is a network problem, not a visibility one; the caller
|
|
# retries before believing it.
|
|
[[ -n $token ]] || return 0
|
|
headers=$(curl -sS -I --max-time 20 -H "Authorization: Bearer $token" -H "Accept: $ACCEPT" \
|
|
"https://${HOST}/v2/${NAMESPACE}/${svc}/manifests/${TAG}" 2>/dev/null) || true
|
|
CODE=$(printf '%s' "$headers" | sed -n 's|^HTTP/[0-9.]* \([0-9][0-9][0-9]\).*|\1|p' | tail -1)
|
|
CODE=${CODE:-000}
|
|
DIGEST=$(printf '%s' "$headers" |
|
|
sed -n 's/[Dd]ocker-[Cc]ontent-[Dd]igest: *//p' | tr -d '\r' | head -1)
|
|
}
|
|
|
|
private=()
|
|
missing=()
|
|
for svc in "${SERVICES[@]}"; do
|
|
probe "$svc"
|
|
# 5xx and a dead token are transient often enough that one retry is worth
|
|
# more than a flaky release gate.
|
|
if [[ $CODE == "000" || $CODE == 5* ]]; then
|
|
sleep 2
|
|
probe "$svc"
|
|
fi
|
|
case $CODE in
|
|
200)
|
|
pass "$svc:$TAG is public${DIGEST:+ ($DIGEST)}"
|
|
if [[ -n $DIGEST ]]; then printf '%s\t%s\n' "$svc" "$DIGEST"; fi
|
|
;;
|
|
403|401)
|
|
# GHCR answers 403 for private and for does-not-exist alike, so this
|
|
# cannot tell them apart and does not pretend to.
|
|
fail "$svc:$TAG is NOT publicly pullable (HTTP $CODE)"
|
|
private+=("$svc")
|
|
;;
|
|
404)
|
|
fail "$svc:$TAG does not exist (HTTP 404)"
|
|
missing+=("$svc")
|
|
;;
|
|
*)
|
|
fail "$svc:$TAG could not be checked (HTTP $CODE)"
|
|
private+=("$svc")
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ ${#private[@]} -eq 0 && ${#missing[@]} -eq 0 ]]; then
|
|
info ""
|
|
printf '\033[32mAll %d images are pullable with no credentials.\033[0m\n' "${#SERVICES[@]}" >&2
|
|
exit 0
|
|
fi
|
|
|
|
broken=()
|
|
if [[ ${#private[@]} -gt 0 ]]; then broken+=("${private[@]}"); fi
|
|
if [[ ${#missing[@]} -gt 0 ]]; then broken+=("${missing[@]}"); fi
|
|
summary="not publicly pullable: ${broken[*]}"
|
|
|
|
info ""
|
|
info " $summary"
|
|
info ""
|
|
info " A package on GHCR is created private and does not inherit the"
|
|
info " repository's visibility. There is no API for the fix; an org owner has"
|
|
info " to do it in the UI, once per package:"
|
|
info ""
|
|
info " 1. https://github.com/organizations/warmbly/settings/packages"
|
|
info " Package Creation must allow Public, or the control below is greyed out."
|
|
info " 2. https://github.com/orgs/warmbly/packages"
|
|
info " each package > Package settings > Danger Zone > Change visibility"
|
|
info ""
|
|
info " It applies to every tag at once and cannot be undone."
|
|
info ""
|
|
|
|
if [[ -n ${GITHUB_ACTIONS:-} ]]; then
|
|
if [[ $WARN_ONLY == 1 ]]; then
|
|
echo "::warning title=Images are not public::$summary. The next release will fail its publicity gate. See scripts/check-images-public.sh."
|
|
else
|
|
echo "::error title=Images are not public::$summary. A self-host install of this release cannot pull them."
|
|
fi
|
|
fi
|
|
|
|
if [[ $WARN_ONLY == 1 ]]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|