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.
363 lines
13 KiB
Bash
Executable File
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"
|