Files
xavierk 731b919865
CI / security (push) Successful in 1m11s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped
chore(release): prepare v0.19.0 (#53)
Version 0.19.0 across version.py, the Void template and the synced docs; changelog entry; Gitea project URLs in pyproject.toml; per-backend ladder thresholds measured in #19; the xbps release workflow is manual-only, and packaging/void/publish-release.sh publishes a locally built, verified repository to the Gitea releases.
2026-11-06 20:23:14 +00:00

363 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Publish an already-built Void release to the fork's Gitea release API (spec #16).
#
# A release is built and verified by packaging/void/build-release.sh, locally on
# the maintainer's x86_64 Void host (packaging/void/README.md, "Releasing"). This
# script publishes that out-dir and nothing else:
#
# publish-release.sh TAG --out-dir DIR [--dry-run]
#
# Before any API request it checks that:
# 1. TAG passes the tag gate (build-release.sh check-tag): exactly v plus
# __version__, and the committed template names this release;
# 2. DIR/BUILT_FROM names TAG and the commit the set was built from;
# 3. origin's refs/tags/TAG resolves to that commit. An annotated tag is compared
# by the commit it peels to; a lightweight tag is compared directly;
# 4. the sdist matches DIR/SDIST_SHA256, the client key is the committed
# packaging/void/repository-key.plist, and the repository has its packages,
# signatures and index.
# It then publishes the staged set the way the Gitea workflow's publish step does
# (.gitea/workflows/xbps-release.yml): the tag's release first, then the rolling
# `latest` release, with the repodata uploaded last.
#
# --dry-run runs every check above, reads no token, makes no API call, and prints
# the calls it would make, in order.
#
# Token: GITEA_TOKEN when it is set. Otherwise the token of the tea login for
# GITEA_HOST in $TEA_CONFIG (default ${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml).
# The token is written to a 0600 header file in a private temporary directory,
# which is removed on exit. It is never printed.
#
# Environment: GITEA_HOST, GITEA_API (the repository's API root), REMOTE (default
# origin), TEA_CONFIG. Tests point them at a stub; a release uses the defaults.
set -euo pipefail
CDPATH=""
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(cd -- "$HERE/../.." && pwd)
PLIST="$HERE/repository-key.plist"
GITEA_HOST=${GITEA_HOST:-https://git.bongbetic.com}
GITEA_API=${GITEA_API:-https://git.bongbetic.com/api/v1/repos/xavierk/voculinux}
REMOTE=${REMOTE:-origin}
TEA_CONFIG=${TEA_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml}
usage() {
echo "usage: publish-release.sh TAG --out-dir DIR [--dry-run]" >&2
exit 2
}
fail() {
echo "publish-release.sh: $*" >&2
exit 1
}
plan() {
printf ' %s\n' "$*"
}
urlencode() {
local s=$1 out="" c i
for ((i = 0; i < ${#s}; i++)); do
c=${s:i:1}
case $c in
[a-zA-Z0-9._~-]) out+=$c ;;
*) out+=$(printf '%%%02X' "'$c") ;;
esac
done
printf '%s' "$out"
}
# Reads the token of the tea login for GITEA_HOST and writes the header file
# named by argv[3]. Prints nothing else; a failure names the config, never a token.
TEA_READER=$(cat <<'PY'
import re
import sys
config, host, out = sys.argv[1:4]
try:
with open(config, encoding="utf-8") as handle:
text = handle.read()
except OSError:
sys.exit(f"no tea config at {config}")
# tea keeps a YAML list of logins; each block opens with "- name:".
for block in re.split(r"^\s*-\s+name:", text, flags=re.M)[1:]:
fields = {}
for line in block.splitlines():
match = re.match(r"\s*(url|token):\s*(.*?)\s*$", line)
if match:
fields[match.group(1)] = match.group(2).strip("'\"")
if fields.get("url", "").rstrip("/") == host and fields.get("token"):
with open(out, "w", encoding="utf-8") as handle:
handle.write("Authorization: token " + fields["token"] + "\n")
sys.exit(0)
sys.exit(f"no tea login for {host} with a token in {config}")
PY
)
TAG=""
OUT=""
DRY_RUN=0
[ "$#" -ge 1 ] || usage
TAG=$1
shift
while [ "$#" -gt 0 ]; do
case $1 in
--out-dir)
[ "$#" -ge 2 ] || usage
OUT=$2
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
*) usage ;;
esac
done
[ -n "$OUT" ] || usage
# Gate 1: the tag gate, offline. Exactly v plus __version__, and the template names it.
"$HERE/build-release.sh" check-tag "$TAG" >/dev/null ||
fail "tag $TAG fails the tag gate (build-release.sh check-tag); refusing to publish"
names=$("$HERE/build-release.sh" names "$TAG") || fail "could not derive the names of $TAG"
SDIST_NAME=$(printf '%s\n' "$names" | sed -n 's/^SDIST_NAME=//p')
# Gate 2: the out-dir is the set build-release.sh built for this tag.
[ -d "$OUT" ] || fail "$OUT is not a directory"
OUT=$(cd -- "$OUT" && pwd)
[ -f "$OUT/BUILT_FROM" ] ||
fail "$OUT has no BUILT_FROM record: it was not written by build-release.sh release"
BUILT_TAG=$(sed -n 's/^tag=//p' "$OUT/BUILT_FROM")
BUILT_COMMIT=$(sed -n 's/^commit=//p' "$OUT/BUILT_FROM")
[ "$BUILT_TAG" = "$TAG" ] || fail "$OUT was built for '$BUILT_TAG', not '$TAG'"
printf '%s\n' "$BUILT_COMMIT" | grep -Eq '^[0-9a-f]{40}$' ||
fail "BUILT_FROM in $OUT names no commit"
shopt -s nullglob
[ -f "$OUT/sdist/$SDIST_NAME" ] && [ "$(find "$OUT/sdist" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 ] ||
fail "$OUT/sdist must hold exactly $SDIST_NAME"
[ -f "$OUT/SDIST_SHA256" ] || fail "$OUT has no SDIST_SHA256"
want_digest=$(cat "$OUT/SDIST_SHA256")
got_digest=$(sha256sum "$OUT/sdist/$SDIST_NAME" | cut -d' ' -f1)
[ "$got_digest" = "$want_digest" ] || fail "sdist/$SDIST_NAME does not match SDIST_SHA256"
[ -f "$OUT/xbps/keys/FINGERPRINT" ] || fail "$OUT has no FINGERPRINT record"
FINGERPRINT=$(sed -n 's/^fingerprint: //p' "$OUT/xbps/keys/FINGERPRINT")
[ -n "$FINGERPRINT" ] || fail "FINGERPRINT in $OUT names no fingerprint"
cmp -s "$PLIST" "$OUT/xbps/keys/$FINGERPRINT.plist" ||
fail "the client key in $OUT is not the committed packaging/void/repository-key.plist"
packages=("$OUT"/xbps/repo/*.x86_64.xbps)
signatures=("$OUT"/xbps/repo/*.sig2)
index=("$OUT"/xbps/repo/*-repodata*)
[ "${#packages[@]}" -gt 0 ] || fail "$OUT holds no packages"
[ "${#signatures[@]}" -eq "${#packages[@]}" ] || fail "every package needs exactly one signature"
[ "${#index[@]}" -gt 0 ] || fail "$OUT has no repository index (x86_64-repodata)"
# Gate 3: origin's tag is the commit the set was built from. Both refs are asked
# for in one call: the tag object, and the commit it peels to (`^{}`).
remote_refs=$(git -C "$ROOT" ls-remote --tags "$REMOTE" "refs/tags/$TAG" "refs/tags/$TAG^{}") ||
fail "could not read the tags of $REMOTE"
direct=$(printf '%s\n' "$remote_refs" | awk -v ref="refs/tags/$TAG" '$2 == ref { print $1 }')
peeled=$(printf '%s\n' "$remote_refs" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref { print $1 }')
[ -n "$direct" ] ||
fail "tag $TAG is not on $REMOTE; push the annotated tag first (packaging/void/README.md)"
[ "$(printf '%s\n' "$direct" | wc -l)" -eq 1 ] || fail "$REMOTE has more than one $TAG"
if [ -n "$peeled" ]; then
REMOTE_COMMIT=$peeled
else
REMOTE_COMMIT=$direct
fi
[ "$REMOTE_COMMIT" = "$BUILT_COMMIT" ] ||
fail "$REMOTE's $TAG points at $REMOTE_COMMIT, but $OUT was built from $BUILT_COMMIT; refusing to publish"
echo "gates passed: $TAG on $REMOTE points at the commit $OUT was built from ($BUILT_COMMIT)"
# The staged set, in upload order: the sdist, packages, signatures and the client
# key first; the repodata last, so no client sees an index for packages not yet up.
cd "$OUT"
FILES=(
sdist/*.tar.gz
xbps/repo/*.x86_64.xbps
xbps/repo/*.sig2
xbps/keys/FINGERPRINT
xbps/keys/*.plist
xbps/repo/*-repodata*
)
NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done)
SDIST_DIGEST=$want_digest
NOTES=$(printf '%s\n\n%s\n%s\n' \
"Signed xbps repository, Void Linux glibc x86_64, built from ${TAG} by packaging/void/build-release.sh and published by packaging/void/publish-release.sh. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
"$(cat xbps/keys/FINGERPRINT)" \
"sdist sha256: ${SDIST_DIGEST} (voculinux-*.tar.gz, the source the packages are built from)")
# Every request goes through api(), which is the only place curl runs. In a dry
# run each helper prints its planned call instead and no request is made.
api() {
local method=$1 url=$2 code
shift 2
code=$(curl -sS -o "$BODY" -w '%{http_code}' -X "$method" -H "@$AUTH" "$@" "$url") ||
fail "request failed: $method $url"
case $code in
2[0-9][0-9]) ;;
*)
cat "$BODY" >&2
echo >&2
fail "$method $url returned HTTP $code"
;;
esac
}
json_field() {
python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$BODY" "$1"
}
release_payload() {
python3 -I -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": sys.argv[3], "body": sys.argv[4], "draft": False, "prerelease": False}))' "$@"
}
# release_id TAG: sets REL_ID to the release's id, or to "" when the tag has no
# release. Sets a variable rather than printing: a failure inside $(...) in an
# argument position would otherwise be swallowed.
release_id() {
local code
REL_ID=""
code=$(curl -sS -o "$BODY" -w '%{http_code}' -H "@$AUTH" \
"$GITEA_API/releases/tags/$(urlencode "$1")") || fail "request failed: GET releases/tags/$1"
case $code in
200) REL_ID=$(json_field id) ;;
404) ;;
*)
cat "$BODY" >&2
echo >&2
fail "GET releases/tags/$1 returned HTTP $code"
;;
esac
}
# ensure_release TAG NAME NOTES: sets REL_ID, creating the release at the built
# commit when the tag has none.
ensure_release() {
local payload
if [ "$DRY_RUN" -eq 1 ]; then
plan "GET $GITEA_API/releases/tags/$1"
plan "POST $GITEA_API/releases only when $1 has no release: tag_name=$1 target_commitish=$BUILT_COMMIT"
REL_ID="<$1 release id>"
return 0
fi
release_id "$1"
if [ -z "$REL_ID" ]; then
payload=$(release_payload "$1" "$BUILT_COMMIT" "$2" "$3")
api POST "$GITEA_API/releases" -H 'Content-Type: application/json' -d "$payload"
REL_ID=$(json_field id)
fi
}
# delete_assets ID MODE: MODE is "all", or "same" for only the assets whose names
# this run uploads again (a rerun on a tag release leaves nothing stale behind).
delete_assets() {
local listing aid aname
if [ "$DRY_RUN" -eq 1 ]; then
plan "GET $GITEA_API/releases/$1/assets"
if [ "$2" = all ]; then
plan "DELETE $GITEA_API/releases/$1/assets/<asset id> for every asset"
else
plan "DELETE $GITEA_API/releases/$1/assets/<asset id> for each asset this set uploads again"
fi
return 0
fi
api GET "$GITEA_API/releases/$1/assets"
listing=$(python3 -I -c 'import json,sys; [print(a["id"], a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY")
[ -n "$listing" ] || return 0
while read -r aid aname; do
if [ "$2" = all ] || printf '%s\n' "$NAMES" | grep -qxF -- "$aname"; then
api DELETE "$GITEA_API/releases/$1/assets/$aid"
echo "deleted: $aname"
fi
done <<<"$listing"
}
upload_assets() {
local f name
for f in "${FILES[@]}"; do
name=$(basename "$f")
if [ "$DRY_RUN" -eq 1 ]; then
plan "POST $GITEA_API/releases/$1/assets?name=$(urlencode "$name") attachment=$f"
continue
fi
api POST "$GITEA_API/releases/$1/assets?name=$(urlencode "$name")" \
-F "attachment=@${f};filename=${name}"
echo "uploaded: $name"
done
}
# verify_assets ID: every file this run uploaded is on the release.
verify_assets() {
local have missing
if [ "$DRY_RUN" -eq 1 ]; then
plan "GET $GITEA_API/releases/$1/assets (every uploaded name must be listed)"
return 0
fi
api GET "$GITEA_API/releases/$1/assets"
have=$(python3 -I -c 'import json,sys; [print(a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY" | sort)
missing=$(printf '%s\n' "$NAMES" | sort | comm -23 - <(printf '%s\n' "$have"))
[ -z "$missing" ] || fail "release $1 is missing uploaded assets: $missing"
}
if [ "$DRY_RUN" -eq 1 ]; then
echo "dry run: no request is sent and no token is read. Planned calls, in order:"
echo " Authorization: token <redacted>, API $GITEA_API"
echo "tag release $TAG (commit $BUILT_COMMIT):"
REL_ID=""
BODY=""
ensure_release "$TAG" "$TAG" "$NOTES"
TAG_ID=$REL_ID
delete_assets "$TAG_ID" same
upload_assets "$TAG_ID"
verify_assets "$TAG_ID"
echo "latest release (rolling, the registration URL serves it):"
ensure_release latest latest "$NOTES"
LATEST_ID=$REL_ID
plan "PATCH $GITEA_API/releases/$LATEST_ID name=latest, body=the same notes, with ${TAG} named"
delete_assets "$LATEST_ID" all
upload_assets "$LATEST_ID"
verify_assets "$LATEST_ID"
echo "dry run complete: nothing was published"
exit 0
fi
# Token: GITEA_TOKEN first (the workflow's job token), then the tea login.
AUTH_DIR=$(mktemp -d "${TMPDIR:-/tmp}/publish-release.XXXXXX")
trap 'rm -rf "$AUTH_DIR"' EXIT
AUTH="$AUTH_DIR/authorization.h"
BODY="$AUTH_DIR/body.json"
umask 077
if [ -n "${GITEA_TOKEN:-}" ]; then
printf 'Authorization: token %s\n' "$GITEA_TOKEN" >"$AUTH"
else
python3 -I -c "$TEA_READER" "$TEA_CONFIG" "$GITEA_HOST" "$AUTH" ||
fail "no tea login for $GITEA_HOST with a token (set GITEA_TOKEN, or log in with tea)"
fi
echo "publishing ${TAG} (built from ${BUILT_COMMIT}) to ${GITEA_API}"
echo "== tag release ${TAG}"
ensure_release "$TAG" "$TAG" "$NOTES"
TAG_ID=$REL_ID
delete_assets "$TAG_ID" same
upload_assets "$TAG_ID"
verify_assets "$TAG_ID"
# The rolling release the registration URL points at. The tag it was created
# with stays where it was first made; its assets are what clients download.
echo "== latest release"
LATEST_NOTES=$(printf '%s\n\n%s\n' \
"Rolling signed xbps repository, currently built from ${TAG} (${BUILT_COMMIT})." \
"$NOTES")
ensure_release latest latest "$LATEST_NOTES"
LATEST_ID=$REL_ID
PATCH_PAYLOAD=$(python3 -I -c 'import json,sys; print(json.dumps({"name": sys.argv[1], "body": sys.argv[2]}))' latest "$LATEST_NOTES")
api PATCH "$GITEA_API/releases/$LATEST_ID" -H 'Content-Type: application/json' -d "$PATCH_PAYLOAD"
delete_assets "$LATEST_ID" all
upload_assets "$LATEST_ID"
verify_assets "$LATEST_ID"
echo "OK: signed repository published to ${TAG} and latest"