fix(void): build the xbps release from the tag, not an attached sdist
The signed Void release depended on a fork sdist attached to the release before the tag existed, and the app template carried a PENDING checksum that had to be filled by hand. Every release is now built from its tag alone: - packaging/void/build-release.sh is the one release path. The Gitea workflow, the local dry run and the smoke gate all call it. - The canonical reproducible sdist is built from the tag with scripts/build_reproducible_wheel.py (Python 3.11, pinned uv). - render-voculinux.sh pins version, _tag and checksum in the container's build tree only, and places the sdist where xbps-src resolves its distfile. The committed template keeps a zero placeholder until the post-release checksum pull request. - xbps version is __version__ with the hyphen removed (0.19.0-beta becomes 0.19.0beta), which xbps sorts below the final release. - Builds run without the key; signing runs with the key mounted read-only and no network; the install smoke runs in a clean image without the key. - The workflow gates the tag and the key before any container starts, writes the key to a 0600 tmpfs file, and publishes only the staged set (sdist, packages, signatures, repodata, client key). - smoke.sh runs the same pipeline with a throwaway key.
This commit is contained in:
Executable
+352
@@ -0,0 +1,352 @@
|
||||
#!/bin/sh
|
||||
# The one Void release path (spec #16 "Release pipeline and upstream intake";
|
||||
# tickets #28 and #29). The Gitea workflow (.gitea/workflows/xbps-release.yml),
|
||||
# a local dry run and the smoke gate (packaging/void/smoke.sh) all call this
|
||||
# script, so a dry run exercises exactly the code a tag runs.
|
||||
#
|
||||
# Usage:
|
||||
# build-release.sh names TAG
|
||||
# print what a tag maps to. Offline; builds nothing.
|
||||
# build-release.sh check-tag TAG
|
||||
# the tag gate. Offline: TAG must be exactly v + __version__ from
|
||||
# src/vocalinux/version.py, and the committed Void template must already
|
||||
# name that version and tag. Exits 1 otherwise.
|
||||
# build-release.sh release TAG --key-file FILE --out-dir DIR
|
||||
# the whole release: gate, reproducible sdist, signed repository,
|
||||
# completeness check. Writes DIR/sdist and DIR/xbps. Publishes nothing;
|
||||
# the workflow publishes DIR. DIR must be absent or empty.
|
||||
# build-release.sh smoke
|
||||
# the same pipeline for the current version, signed with a throwaway
|
||||
# RSA4096 key in a temporary directory. Proves the path works; nothing is
|
||||
# signed with the release key and nothing is published.
|
||||
#
|
||||
# Version mapping. xbps versions cannot contain '-', so the xbps spelling of a
|
||||
# release is __version__ with the hyphen removed: 0.18.5 -> 0.18.5,
|
||||
# 0.19.0-beta -> 0.19.0beta, 0.19.0-rc1 -> 0.19.0rc1. xbps sorts the pre-release
|
||||
# forms below the final release (checked with xbps-uhelper cmpver in
|
||||
# tests/test_void_release_build.py). Only X.Y.Z, X.Y.Z-alpha, X.Y.Z-beta and
|
||||
# X.Y.Z-rcN are release forms; anything else is refused.
|
||||
#
|
||||
# The distfile and the release asset are both voculinux-<xbps version>.tar.gz.
|
||||
# The sdist keeps its PEP 440 name inside (voculinux-0.19.0b0/); the xbps-src
|
||||
# do-extract hook renames the sdist's single top-level directory to the
|
||||
# package's wrksrc, so the two spellings need not match.
|
||||
#
|
||||
# Environment: PYTHON (default python3.11) and uv 0.12.x or 0.13.x on PATH, both
|
||||
# for the sdist. The signing key is never echoed; it reaches the signing
|
||||
# container only as a read-only mount.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
TEMPLATE="$HERE/srcpkgs/voculinux/template"
|
||||
PLIST="$HERE/repository-key.plist"
|
||||
PYTHON=${PYTHON:-python3.11}
|
||||
SIGNEDBY="Voculinux <xbps@voculinux.com>"
|
||||
MODE=""
|
||||
WORK=""
|
||||
|
||||
fail() {
|
||||
echo "build-release.sh: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: build-release.sh names TAG
|
||||
build-release.sh check-tag TAG
|
||||
build-release.sh release TAG --key-file FILE --out-dir DIR
|
||||
build-release.sh smoke
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || fail "pins missing from tool_checksums.txt"
|
||||
# The package order is owned by smoke.sh and shared with every release path.
|
||||
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
|
||||
[ -n "$PKGS" ] || fail "could not read the package order from smoke.sh"
|
||||
|
||||
# source_version: __version__ from src/vocalinux/version.py, defined once.
|
||||
source_version() {
|
||||
count=$(sed -n '/^__version__ = /p' "$ROOT/src/vocalinux/version.py" | wc -l)
|
||||
[ "$count" -eq 1 ] || fail "src/vocalinux/version.py must define __version__ exactly once"
|
||||
value=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "$ROOT/src/vocalinux/version.py")
|
||||
[ -n "$value" ] || fail "could not read __version__ from src/vocalinux/version.py"
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
# xbps_version VERSION: the xbps spelling, or nothing when VERSION is not a
|
||||
# release form.
|
||||
xbps_version() {
|
||||
printf '%s\n' "$1" |
|
||||
sed -n -E 's/^([0-9]+(\.[0-9]+)*)(-(alpha|beta|rc)[0-9]*)?$/\1\3/p' |
|
||||
tr -d '-'
|
||||
}
|
||||
|
||||
# derive TAG: sets TAG, VERSION, XBPS_VERSION and SDIST_NAME, or fails.
|
||||
derive() {
|
||||
TAG=${1:-}
|
||||
VERSION=$(source_version)
|
||||
[ "$TAG" = "v$VERSION" ] ||
|
||||
fail "tag '$TAG' is not exactly v$VERSION (src/vocalinux/version.py); refusing to publish"
|
||||
XBPS_VERSION=$(xbps_version "$VERSION")
|
||||
[ -n "$XBPS_VERSION" ] ||
|
||||
fail "version $VERSION is not a release form (X.Y.Z, or X.Y.Z-alpha, -beta, -rcN)"
|
||||
SDIST_NAME="voculinux-$XBPS_VERSION.tar.gz"
|
||||
}
|
||||
|
||||
# template_value KEY: the value of the single KEY= line of the committed template.
|
||||
template_value() {
|
||||
count=$(grep -c "^$1=" "$TEMPLATE" || true)
|
||||
[ "$count" -eq 1 ] || fail "$TEMPLATE must hold exactly one $1= line (found $count)"
|
||||
sed -n "s/^$1=//p" "$TEMPLATE"
|
||||
}
|
||||
|
||||
# check_template: the committed template must already name this release. The
|
||||
# build tree is pinned from the tag; the committed file is what a reader sees
|
||||
# and what the next release starts from.
|
||||
check_template() {
|
||||
have=$(template_value version)
|
||||
[ "$have" = "$XBPS_VERSION" ] ||
|
||||
fail "committed template version is '$have', not '$XBPS_VERSION' for $TAG; update packaging/void/srcpkgs/voculinux/template in the release pull request"
|
||||
have=$(template_value _tag)
|
||||
[ "$have" = "$TAG" ] ||
|
||||
fail "committed template _tag is '$have', not '$TAG'; update packaging/void/srcpkgs/voculinux/template"
|
||||
}
|
||||
|
||||
# check_key KEY_FILE: RSA 4096, and for a release the public half must be the
|
||||
# committed repository-key.plist key. Sets FINGERPRINT and CLIENT_PLIST.
|
||||
check_key() {
|
||||
KEY_FILE=$1
|
||||
[ -n "$KEY_FILE" ] ||
|
||||
fail "no signing key: pass --key-file (the workflow writes the XBPS_SIGNING_KEY secret to a file first)"
|
||||
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] ||
|
||||
fail "signing key missing or empty; refusing to publish an unsigned repository"
|
||||
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 ||
|
||||
fail "the signing key is not a valid RSA private key; refusing to publish"
|
||||
openssl rsa -in "$KEY_FILE" -noout -text 2>/dev/null | head -n1 | grep -q "4096 bit" ||
|
||||
fail "the signing key is not RSA 4096; refusing to publish"
|
||||
openssl rsa -in "$KEY_FILE" -pubout -out "$WORK/pub.pem" 2>/dev/null ||
|
||||
fail "could not derive the public key from the signing key"
|
||||
FINGERPRINT=$(python3 -I "$HERE/xbps-fingerprint.py" fingerprint "$WORK/pub.pem")
|
||||
if [ "$MODE" = release ]; then
|
||||
python3 -I -c 'import plistlib, sys; sys.stdout.buffer.write(plistlib.load(open(sys.argv[1], "rb"))["public-key"])' \
|
||||
"$PLIST" >"$WORK/plist-pub.pem"
|
||||
cmp -s "$WORK/pub.pem" "$WORK/plist-pub.pem" ||
|
||||
fail "the signing key does not match packaging/void/repository-key.plist; refusing to publish"
|
||||
cp "$PLIST" "$WORK/client.plist"
|
||||
else
|
||||
python3 -I "$HERE/xbps-fingerprint.py" plist "$WORK/pub.pem" "$SIGNEDBY" "$WORK/client.plist"
|
||||
fi
|
||||
echo "signing key: RSA 4096, fingerprint $FINGERPRINT ($MODE)"
|
||||
}
|
||||
|
||||
# build_sdist: the canonical reproducible sdist, copied under its xbps name.
|
||||
build_sdist() {
|
||||
command -v "$PYTHON" >/dev/null 2>&1 ||
|
||||
fail "$PYTHON not found: the sdist is built with Python 3.11 (set PYTHON)"
|
||||
"$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 11) else 1)' ||
|
||||
fail "$PYTHON is not Python 3.11"
|
||||
command -v uv >/dev/null 2>&1 || fail "uv not found: the release pins uv 0.12.x or 0.13.x"
|
||||
UV_VERSION=$(uv --version | awk '{print $2}')
|
||||
case "$UV_VERSION" in
|
||||
0.12.* | 0.13.*) ;;
|
||||
*) fail "uv $UV_VERSION is outside the pinned 0.12.x / 0.13.x range" ;;
|
||||
esac
|
||||
(cd "$ROOT" && "$PYTHON" scripts/build_reproducible_wheel.py --python "$PYTHON" \
|
||||
--output "$WORK/dist" --diagnostics "$WORK/diagnostics") ||
|
||||
fail "the reproducible build failed; diagnostics are in $WORK/diagnostics"
|
||||
set -- "$WORK"/dist/voculinux-*.tar.gz
|
||||
[ "$#" -eq 1 ] && [ -f "$1" ] || fail "expected exactly one sdist in $WORK/dist"
|
||||
mkdir -p "$WORK/inputs"
|
||||
cp "$1" "$WORK/inputs/$SDIST_NAME"
|
||||
SDIST_SHA256=$(sha256sum "$WORK/inputs/$SDIST_NAME" | cut -d' ' -f1)
|
||||
echo "sdist: $SDIST_NAME sha256 $SDIST_SHA256"
|
||||
}
|
||||
|
||||
# The podman phases. Only the build phase runs build scripts (with network);
|
||||
# only the sign phase sees the key (and has no network); only the install phase
|
||||
# runs the packages (in a clean image, without the key).
|
||||
phase_build() {
|
||||
mkdir -p "$WORK/packages"
|
||||
podman pull -q "$BASE_IMAGE" >/dev/null
|
||||
podman run --rm \
|
||||
-v "$HERE/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
|
||||
-v "$HERE/render-voculinux.sh:/mnt/render-voculinux.sh:ro" \
|
||||
-v "$WORK/inputs:/mnt/inputs:ro" \
|
||||
-v "$WORK/packages:/mnt/packages" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "TAG=$TAG" \
|
||||
-e "XBPS_VERSION=$XBPS_VERSION" \
|
||||
-e "SDIST_NAME=$SDIST_NAME" \
|
||||
-e "SDIST_SHA256=$SDIST_SHA256" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh build
|
||||
}
|
||||
|
||||
phase_sign() {
|
||||
mkdir -p "$OUT/xbps"
|
||||
podman run --rm --network none \
|
||||
-v "$WORK/packages:/mnt/packages:ro" \
|
||||
-v "$OUT/xbps:/mnt/xbps" \
|
||||
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
|
||||
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
|
||||
-e "SIGNEDBY=$SIGNEDBY" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh sign
|
||||
mkdir -p "$OUT/xbps/keys"
|
||||
cp "$WORK/client.plist" "$OUT/xbps/keys/$FINGERPRINT.plist"
|
||||
printf 'signed-by: %s\nfingerprint: %s\n' "$SIGNEDBY" "$FINGERPRINT" >"$OUT/xbps/keys/FINGERPRINT"
|
||||
}
|
||||
|
||||
phase_install() {
|
||||
podman run --rm \
|
||||
-v "$OUT/xbps:/mnt/xbps:ro" \
|
||||
-v "$WORK/packages:/mnt/packages:ro" \
|
||||
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "XBPS_VERSION=$XBPS_VERSION" \
|
||||
-e "VERSION=$VERSION" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh install
|
||||
}
|
||||
|
||||
# verify_set: the staged set is complete and holds nothing that may not be
|
||||
# published.
|
||||
verify_set() {
|
||||
cd "$OUT"
|
||||
for pattern in 'sdist/*.tar.gz' 'xbps/repo/*.x86_64.xbps' 'xbps/repo/*.sig2' \
|
||||
'xbps/repo/*-repodata*' 'xbps/keys/FINGERPRINT' 'xbps/keys/*.plist'; do
|
||||
compgen -G "$pattern" >/dev/null || fail "missing from the signed set: $pattern"
|
||||
done
|
||||
[ "$(ls sdist | wc -l)" -eq 1 ] && [ -f "sdist/$SDIST_NAME" ] ||
|
||||
fail "the sdist directory must hold exactly sdist/$SDIST_NAME"
|
||||
[ "$(sha256sum "sdist/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
|
||||
fail "the staged sdist does not match its digest"
|
||||
for f in xbps/repo/*; do
|
||||
base=${f##*/}
|
||||
case "$base" in
|
||||
*musl*) fail "musl data in the repository: $base" ;;
|
||||
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
|
||||
*) fail "unexpected file (only glibc x86_64 data is published): xbps/repo/$base" ;;
|
||||
esac
|
||||
done
|
||||
for f in xbps/keys/*; do
|
||||
base=${f##*/}
|
||||
case "$base" in
|
||||
FINGERPRINT | *.plist) ;;
|
||||
*) fail "unexpected file among the client keys: xbps/keys/$base" ;;
|
||||
esac
|
||||
done
|
||||
for pkg in xbps/repo/*.x86_64.xbps; do
|
||||
[ -s "$pkg.sig2" ] || fail "package has no signature: $pkg"
|
||||
done
|
||||
for sig in xbps/repo/*.sig2; do
|
||||
[ -f "${sig%.sig2}" ] || fail "signature without its package: $sig"
|
||||
done
|
||||
for name in $PKGS; do
|
||||
if [ "$name" = voculinux ]; then
|
||||
[ -f "xbps/repo/voculinux-${XBPS_VERSION}_1.x86_64.xbps" ] ||
|
||||
fail "the repository lacks voculinux-${XBPS_VERSION}_1"
|
||||
else
|
||||
compgen -G "xbps/repo/${name}-[0-9]*.x86_64.xbps" >/dev/null ||
|
||||
fail "package missing from the repository: $name"
|
||||
fi
|
||||
done
|
||||
FP=$(sed -n 's/^fingerprint: //p' xbps/keys/FINGERPRINT)
|
||||
[ "$FP" = "$FINGERPRINT" ] || fail "FINGERPRINT says '$FP', the signing key is '$FINGERPRINT'"
|
||||
[ -s "xbps/keys/$FINGERPRINT.plist" ] || fail "client key xbps/keys/$FINGERPRINT.plist is missing"
|
||||
if [ "$MODE" = release ]; then
|
||||
cmp -s "$PLIST" "xbps/keys/$FINGERPRINT.plist" ||
|
||||
fail "the staged client key is not the committed repository-key.plist"
|
||||
fi
|
||||
cd "$ROOT"
|
||||
echo "OK: complete signed glibc x86_64 set for $TAG, key $FINGERPRINT"
|
||||
}
|
||||
|
||||
run_pipeline() {
|
||||
derive "$1"
|
||||
check_template
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/voculinux-release.XXXXXX")
|
||||
cleanup() {
|
||||
status=$?
|
||||
rm -f "$WORK/throwaway.pem"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
rm -rf "$WORK"
|
||||
else
|
||||
echo "build-release.sh: failed; diagnostics retained in $WORK" >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
if [ "$MODE" = smoke ]; then
|
||||
openssl genrsa -out "$WORK/throwaway.pem" 4096 2>/dev/null
|
||||
KEY_FILE="$WORK/throwaway.pem"
|
||||
OUT="$WORK/out"
|
||||
fi
|
||||
[ -e "$OUT" ] && { [ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ]; } ||
|
||||
{ [ ! -e "$OUT" ] || fail "$OUT exists and is not empty; choose a new output directory"; }
|
||||
check_key "$KEY_FILE"
|
||||
build_sdist
|
||||
phase_build
|
||||
# The committed templates must be untouched: the build tree is the only
|
||||
# place a release pins version, _tag and checksum.
|
||||
if git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
git -C "$ROOT" diff --quiet -- packaging/void/srcpkgs ||
|
||||
fail "the build changed the committed templates"
|
||||
fi
|
||||
mkdir -p "$OUT"
|
||||
OUT=$(cd "$OUT" && pwd)
|
||||
phase_sign
|
||||
phase_install
|
||||
verify_set
|
||||
printf '%s\n' "$SDIST_SHA256" >"$OUT/SDIST_SHA256"
|
||||
echo "template bump for the post-release pull request: checksum=$SDIST_SHA256"
|
||||
}
|
||||
|
||||
cmd=${1:-}
|
||||
[ -n "$cmd" ] || usage
|
||||
shift
|
||||
|
||||
case "$cmd" in
|
||||
names)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
derive "$1"
|
||||
printf 'TAG=%s\nVERSION=%s\nXBPS_VERSION=%s\nSDIST_NAME=%s\n' \
|
||||
"$TAG" "$VERSION" "$XBPS_VERSION" "$SDIST_NAME"
|
||||
;;
|
||||
check-tag)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
derive "$1"
|
||||
check_template
|
||||
echo "tag gate passed: $TAG (xbps $XBPS_VERSION)"
|
||||
;;
|
||||
release)
|
||||
[ "$#" -ge 1 ] || usage
|
||||
TAG=$1
|
||||
shift
|
||||
KEY_FILE=""
|
||||
OUT=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--key-file) KEY_FILE=${2:-}; shift 2 ;;
|
||||
--out-dir) OUT=${2:-}; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$OUT" ] || usage
|
||||
MODE=release
|
||||
run_pipeline "$TAG"
|
||||
echo "RELEASE BUILT: $TAG staged in $OUT (not published by this script)"
|
||||
;;
|
||||
smoke)
|
||||
[ "$#" -eq 0 ] || usage
|
||||
MODE=smoke
|
||||
run_pipeline "v$(source_version)"
|
||||
echo "SMOKE PASSED: $TAG built, signed with a throwaway key, installed and smoked; nothing published"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Host-side harness for packaging/void: runs the container flow (see
|
||||
# container-main.sh and container-build.sh) in the pinned Void glibc
|
||||
# container via podman, never Docker.
|
||||
#
|
||||
# Usage: packaging/void/build.sh <pkgname> [-- <xbps-src args...>]
|
||||
#
|
||||
# packaging/void/build.sh python3-pywhispercpp
|
||||
# packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg
|
||||
#
|
||||
# Anything after -- is passed to `xbps-src` before the package name (use it
|
||||
# for -o ~vulkan CPU-only rebuilds). The package build itself fetches
|
||||
# nothing from the network beyond the template's declared distfiles:
|
||||
# xbps-src verifies distfile checksums before unpacking.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
PKGNAME=${1:?usage: build.sh "<pkgname>" [-- "<xbps-src args>"]}
|
||||
shift
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
XBPS_ARGS=${*:-pkg}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
|
||||
echo "build.sh: pins missing from tool_checksums.txt" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$XBPS_ARGS" in
|
||||
*~vulkan*) EXPECT_VULKAN=0 ;;
|
||||
*) EXPECT_VULKAN=1 ;;
|
||||
esac
|
||||
|
||||
echo "Pulling pinned base image: $BASE_IMAGE"
|
||||
podman pull "$BASE_IMAGE"
|
||||
|
||||
podman run --rm \
|
||||
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-main.sh:/mnt/container-main.sh:ro" \
|
||||
-v "$HERE/container-build.sh:/mnt/container-build.sh:ro" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGNAME=$PKGNAME" \
|
||||
-e "XBPS_ARGS=$XBPS_ARGS" \
|
||||
-e "EXPECT_VULKAN=$EXPECT_VULKAN" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-main.sh
|
||||
echo "OK: $PKGNAME built and imported cleanly"
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Builder-side half of packaging/void/build.sh. Runs inside the pinned Void
|
||||
# container as the unprivileged builder user (xbps-src refuses root).
|
||||
#
|
||||
# Not executed directly: build.sh mounts the repo's srcpkgs next to this
|
||||
# file, copies it into the container, and runs it after cloning
|
||||
# void-packages at the pinned commit. PKGNAME and XBPS_ARGS arrive via the
|
||||
# environment. Keeping this a real file (instead of an inline `sh -c`
|
||||
# string) means normal quoting, heredocs, and shellcheck apply.
|
||||
set -eu
|
||||
|
||||
export HOME=/home/builder
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
cd /void-packages
|
||||
|
||||
echo "== xlint $PKGNAME"
|
||||
xlint "srcpkgs/$PKGNAME/template"
|
||||
|
||||
echo "== show-options $PKGNAME"
|
||||
./xbps-src show-options "$PKGNAME" || true
|
||||
|
||||
echo "== xbps-src $XBPS_ARGS $PKGNAME"
|
||||
# shellcheck disable=SC2086
|
||||
./xbps-src $XBPS_ARGS "$PKGNAME"
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Root-side half of the packaging/void container harness. Runs inside the
|
||||
# pinned Void container as root: system setup, the void-packages checkout,
|
||||
# then the builder half (container-build.sh) as the builder user, then the
|
||||
# install plus import smoke as root again.
|
||||
#
|
||||
# Not executed directly: build.sh mounts this file and runs it. PKGNAME,
|
||||
# XBPS_ARGS, EXPECT_VULKAN and VOID_COMMIT arrive via the environment.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
cp /mnt/container-build.sh /run-build.sh
|
||||
chown builder:builder /void-packages /run-build.sh
|
||||
chmod +x /run-build.sh
|
||||
su -s /bin/sh builder -c /run-build.sh
|
||||
|
||||
echo "== install $PKGNAME from the local repository"
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKGNAME"-*.x86_64.xbps /host/binpkgs/"$PKGNAME"-*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || {
|
||||
echo "built package not found" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "built: $PKGFILE"
|
||||
xbps-install -Sy >/dev/null
|
||||
xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/dev/null
|
||||
|
||||
case "$PKGNAME" in
|
||||
voculinux)
|
||||
echo "== payload $PKGNAME: desktop entry, icons, packaged marker"
|
||||
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/applications/voculinux.desktop" || {
|
||||
echo "renamed desktop entry missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" || {
|
||||
echo "renamed icons missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/voculinux/packaged" || {
|
||||
echo "packaged-install marker missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "== payload $PKGNAME: no speech models, no vendored wheels"
|
||||
if tar -tf "$PKGFILE" 2>/dev/null | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then
|
||||
echo "package payload carries models or wheels" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "no models or wheels in the shipped package"
|
||||
echo "== payload $PKGNAME: no pre-rename leftovers"
|
||||
if tar -tf "$PKGFILE" 2>/dev/null | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then
|
||||
echo "pre-rename file names in the OS-visible payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "no pre-rename leftovers in the shipped package"
|
||||
echo "== version and import smoke $PKGNAME"
|
||||
voculinux --version
|
||||
cat > /voculinux-smoke.py <<'PYEOF'
|
||||
# The importable package keeps its retained internal name (vocalinux); the
|
||||
# distribution metadata is the fork's own name (voculinux).
|
||||
import importlib
|
||||
|
||||
MODULES = (
|
||||
"vocalinux.main",
|
||||
"vocalinux.ui.tray_indicator",
|
||||
"vocalinux.speech_recognition.recognition_manager",
|
||||
"vocalinux.speech_recognition.command_processor",
|
||||
"vocalinux.text_injection.text_injector",
|
||||
"vocalinux.text_injection.ibus_engine",
|
||||
"vocalinux.ui.keyboard_backends",
|
||||
"pywhispercpp.model",
|
||||
)
|
||||
for module in MODULES:
|
||||
importlib.import_module(module)
|
||||
print("import-smoke OK:", ", ".join(MODULES))
|
||||
|
||||
from importlib.metadata import version
|
||||
print("voculinux", version("voculinux"))
|
||||
PYEOF
|
||||
python3 /voculinux-smoke.py
|
||||
;;
|
||||
python3-pywhispercpp)
|
||||
echo "== compiler flag scan of the shipped ggml library"
|
||||
LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1)
|
||||
[ -n "$LIBMEMBER" ] || {
|
||||
echo "libggml-cpu missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "member: $LIBMEMBER"
|
||||
mkdir -p /flagscan
|
||||
tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER"
|
||||
LIBSCAN="/flagscan/${LIBMEMBER#./}"
|
||||
echo "-- .GCC.command.line:"
|
||||
readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null || echo "(section absent)"
|
||||
echo "-- .comment:"
|
||||
readelf -p .comment "$LIBSCAN" 2>/dev/null || echo "(section absent)"
|
||||
if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then
|
||||
echo "host-native CPU tuning in the shipped library" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "no host-native tuning in the shipped library"
|
||||
|
||||
echo "== import smoke $PKGNAME"
|
||||
python3 -c "from pywhispercpp.model import Model; print('model submodule ok')"
|
||||
cat > /smoke.py <<'PYEOF'
|
||||
import glob
|
||||
import os
|
||||
import pywhispercpp
|
||||
pkgdir = os.path.dirname(pywhispercpp.__file__)
|
||||
sitedir = os.path.dirname(pkgdir)
|
||||
libs = sorted(os.path.basename(p) for p in glob.glob(sitedir + "/lib*.so*"))
|
||||
print("shipped libraries:", libs)
|
||||
assert any(n.startswith("libwhisper") for n in libs), "libwhisper missing"
|
||||
exts = [n for n in os.listdir(sitedir) if n.startswith("_pywhispercpp") and n.endswith(".so")]
|
||||
assert exts, "compiled extension missing"
|
||||
print("extension:", exts)
|
||||
has_vulkan = any("vulkan" in n for n in libs)
|
||||
want_vulkan = bool(int(os.environ["EXPECT_VULKAN"]))
|
||||
assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (has_vulkan, want_vulkan)
|
||||
print("smoke ok")
|
||||
PYEOF
|
||||
EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py
|
||||
;;
|
||||
python3-PyAudio)
|
||||
echo "== import smoke $PKGNAME"
|
||||
python3 -c "import pyaudio; print('pyaudio', pyaudio.__version__)"
|
||||
;;
|
||||
python3-pynput)
|
||||
# pynput selects a display backend at import and raises without one;
|
||||
# the container has no display, so presence plus the installed
|
||||
# version (the same proof the native smoke accepts) is the smoke.
|
||||
echo "== import smoke $PKGNAME"
|
||||
python3 -c "import importlib.metadata, importlib.util; assert importlib.util.find_spec('pynput'), 'pynput not importable'; print('pynput', importlib.metadata.version('pynput'))"
|
||||
;;
|
||||
*)
|
||||
echo "no smoke recipe for package '$PKGNAME' — add one alongside the template" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/bin/sh
|
||||
# Container half of packaging/void/build-release.sh (tickets #28 and #29).
|
||||
# Runs inside the pinned Void glibc image, once per phase. Each phase gets only
|
||||
# the privileges it needs:
|
||||
#
|
||||
# build root, network on, NO signing key. Clones void-packages at the
|
||||
# pinned commit, renders the voculinux build tree from the tag, and
|
||||
# builds every template in smoke-gate order as the unprivileged
|
||||
# builder user (xbps-src refuses root). Build scripts run here, so
|
||||
# the key is never mounted in this phase.
|
||||
# sign root, NO network, the RSA4096 key mounted read-only. Runs no
|
||||
# build code: it indexes and signs the packages the build produced.
|
||||
# install root, network on (the official repository supplies dependencies),
|
||||
# NO key. In a clean image, installs voculinux from the staged signed
|
||||
# repository and checks what a user sees: the version, the payload,
|
||||
# the imports, and that the repository is refused until its key is
|
||||
# trusted.
|
||||
#
|
||||
# Not executed directly: build-release.sh mounts this file and runs it with one
|
||||
# phase argument. Inputs arrive as environment variables set there.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
KEY=/run/xbps-sign-key.pem
|
||||
PKG_STAGE=/mnt/packages
|
||||
XBPS_STAGE=/mnt/xbps
|
||||
OFFICIAL=https://repo-default.voidlinux.org/current
|
||||
|
||||
fail() {
|
||||
echo "container-release.sh: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
phase_build() {
|
||||
: "${VOID_COMMIT:?}" "${PKGS:?}" "${XBPS_VERSION:?}" "${SDIST_NAME:?}"
|
||||
export VOID_COMMIT PKGS XBPS_VERSION SDIST_NAME
|
||||
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
|
||||
# Build tree only: the committed template in the repository is never changed.
|
||||
for PKG in $PKGS; do
|
||||
if [ "$PKG" = voculinux ]; then
|
||||
VOID_PACKAGES=/void-packages SDIST_DIR=/mnt/inputs sh /mnt/render-voculinux.sh
|
||||
fi
|
||||
done
|
||||
|
||||
chown -R builder:builder /void-packages
|
||||
su -s /bin/sh builder -c 'cd /void-packages && ./xbps-src binary-bootstrap'
|
||||
for PKG in $PKGS; do
|
||||
echo "== xlint $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'"
|
||||
echo "== xbps-src pkg $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'"
|
||||
done
|
||||
|
||||
# The distfile the template names must be the sdist staged for it.
|
||||
su -s /bin/sh builder -c 'cd /void-packages && ./xbps-src show voculinux' >/tmp/show.txt 2>&1 ||
|
||||
fail "xbps-src show voculinux failed"
|
||||
grep -qF "/$SDIST_NAME" /tmp/show.txt || fail "xbps-src does not resolve distfile $SDIST_NAME"
|
||||
|
||||
mkdir -p "$PKG_STAGE"
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-[0-9]*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || fail "built package missing from hostdir/binpkgs: $PKG"
|
||||
echo "collected: $(basename "$PKGFILE")"
|
||||
cp "$PKGFILE" "$PKG_STAGE/"
|
||||
done
|
||||
}
|
||||
|
||||
phase_sign() {
|
||||
: "${SIGNEDBY:?}"
|
||||
[ -r "$KEY" ] && [ -s "$KEY" ] || fail "signing key is not mounted at $KEY"
|
||||
mkdir -p "$XBPS_STAGE/repo"
|
||||
cp "$PKG_STAGE"/*.x86_64.xbps "$XBPS_STAGE/repo/"
|
||||
|
||||
echo "== index the full repository"
|
||||
xbps-rindex -a "$XBPS_STAGE"/repo/*.x86_64.xbps
|
||||
echo "== sign every binary package"
|
||||
xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$XBPS_STAGE"/repo/*.x86_64.xbps
|
||||
echo "== sign the repository metadata"
|
||||
xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$XBPS_STAGE/repo"
|
||||
echo "signed repository staged: $(ls "$XBPS_STAGE/repo" | wc -l) files"
|
||||
}
|
||||
|
||||
# The payload checks the package must pass before anyone installs it.
|
||||
payload_voculinux() {
|
||||
PKGFILE=$1
|
||||
tar -tf "$PKGFILE" | grep -q "usr/share/applications/voculinux.desktop" ||
|
||||
fail "renamed desktop entry missing from the package"
|
||||
tar -tf "$PKGFILE" | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" ||
|
||||
fail "renamed icon missing from the package"
|
||||
tar -tf "$PKGFILE" | grep -q "usr/share/voculinux/packaged" ||
|
||||
fail "packaged-install marker missing from the package"
|
||||
if tar -tf "$PKGFILE" | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then
|
||||
fail "package payload carries models or wheels"
|
||||
fi
|
||||
if tar -tf "$PKGFILE" | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then
|
||||
fail "pre-rename file names in the OS-visible payload"
|
||||
fi
|
||||
echo "payload ok: desktop entry, icon and marker present; no models, wheels or pre-rename names"
|
||||
}
|
||||
|
||||
# whisper.cpp must ship its Vulkan backend and no host-native CPU tuning.
|
||||
payload_pywhispercpp() {
|
||||
PKGFILE=$1
|
||||
tar -tf "$PKGFILE" | grep -q "libggml-vulkan" || fail "the Vulkan backend is missing from $PKGFILE"
|
||||
LIBMEMBER=$(tar -tf "$PKGFILE" | grep -E "libggml-cpu\.so" | head -n1)
|
||||
[ -n "$LIBMEMBER" ] || fail "libggml-cpu is missing from $PKGFILE"
|
||||
mkdir -p /flagscan
|
||||
tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER"
|
||||
LIBSCAN="/flagscan/${LIBMEMBER#./}"
|
||||
if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then
|
||||
fail "host-native CPU tuning in the shipped libggml-cpu"
|
||||
fi
|
||||
echo "payload ok: Vulkan backend present; no host-native tuning in libggml-cpu"
|
||||
}
|
||||
|
||||
phase_install() {
|
||||
: "${PKGS:?}" "${XBPS_VERSION:?}" "${VERSION:?}"
|
||||
[ -d "$XBPS_STAGE/repo" ] && [ -d "$XBPS_STAGE/keys" ] || fail "staged repository or keys are missing"
|
||||
xbps-install -Sy xbps binutils >/dev/null
|
||||
|
||||
echo "== the signed repository is refused while its key is not trusted"
|
||||
# No -y: the documented first sync must not accept an unknown key.
|
||||
set +e
|
||||
REFUSAL=$(xbps-install -S --repository="$XBPS_STAGE/repo" voculinux </dev/null 2>&1)
|
||||
REFUSAL_RC=$?
|
||||
set -e
|
||||
printf '%s\n' "$REFUSAL" | grep -i "fingerprint" || true
|
||||
[ "$REFUSAL_RC" -ne 0 ] || fail "the repository installed without its key being trusted"
|
||||
xbps-query voculinux >/dev/null 2>&1 && fail "voculinux was installed by the untrusted attempt"
|
||||
echo "refused without the key, as required (rc=$REFUSAL_RC)"
|
||||
|
||||
echo "== trust the staged key, then install voculinux from the signed repository"
|
||||
cp "$XBPS_STAGE"/keys/*.plist /var/db/xbps/keys/
|
||||
xbps-install -y --repository="$XBPS_STAGE/repo" --repository="$OFFICIAL" -S voculinux >/dev/null
|
||||
xbps-query voculinux | grep -qx "pkgver: voculinux-${XBPS_VERSION}_1" ||
|
||||
fail "installed package is not voculinux-${XBPS_VERSION}_1"
|
||||
echo "installed: voculinux-${XBPS_VERSION}_1"
|
||||
|
||||
echo "== the renamed binary reports its version"
|
||||
VERSION_OUT=$(voculinux --version 2>&1) || fail "voculinux --version exited non-zero"
|
||||
printf '%s\n' "$VERSION_OUT"
|
||||
printf '%s' "$VERSION_OUT" | grep -qF "$VERSION" || fail "voculinux --version does not show $VERSION"
|
||||
|
||||
echo "== payload of the built packages"
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls "$PKG_STAGE"/"$PKG"-[0-9]*.x86_64.xbps | head -n1)
|
||||
case "$PKG" in
|
||||
voculinux) payload_voculinux "$PKGFILE" ;;
|
||||
python3-pywhispercpp) payload_pywhispercpp "$PKGFILE" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "== imports, without a display or hardware"
|
||||
# pynput: the dummy backend proves the package and its loader. The X backend
|
||||
# needs an X server and is covered by the manual hardware checklist.
|
||||
PYNPUT_BACKEND=dummy python3 -c "import pynput.keyboard, pynput.mouse; print('pynput dummy backend ok')"
|
||||
python3 -c "import evdev; print('evdev ok')"
|
||||
python3 -c "import gi; gi.require_version('Gtk', '3.0'); from gi.repository import Gtk; print('gi ok: Gtk', Gtk.get_major_version(), Gtk.get_minor_version())"
|
||||
python3 -c "from pywhispercpp.model import Model; print('pywhispercpp.model ok')"
|
||||
python3 -c "import pyaudio; print('pyaudio ok')"
|
||||
python3 -c "import vocalinux.main, vocalinux.speech_recognition.recognition_manager; print('vocalinux imports ok')"
|
||||
echo "INSTALL SMOKE OK: voculinux $VERSION installed from the signed repository"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
build) phase_build ;;
|
||||
sign) phase_sign ;;
|
||||
install) phase_install ;;
|
||||
*) fail "usage: container-release.sh build|sign|install" ;;
|
||||
esac
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Repository half of packaging/void/repo.sh (ticket #29). Runs inside the
|
||||
# pinned Void container as root: system setup, the void-packages checkout
|
||||
# at the pinned commit, then every template built in smoke-gate order as
|
||||
# the unprivileged builder user (xbps-src refuses root), then index, sign,
|
||||
# verify and stage as root again.
|
||||
#
|
||||
# Not executed directly: repo.sh mounts this file and runs it. VOID_COMMIT,
|
||||
# PKGS and SIGNEDBY arrive via the environment; the RSA4096 private key is
|
||||
# mounted read-only at /run/xbps-sign-key.pem (repo.sh validated it before
|
||||
# any container ran).
|
||||
#
|
||||
# Keeping this a real file (instead of an inline `sh -c` string) means
|
||||
# normal quoting, heredocs, and shellcheck apply.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
KEY=/run/xbps-sign-key.pem
|
||||
OUT=/mnt/out
|
||||
REPO="$OUT/repo"
|
||||
KEYS="$OUT/keys"
|
||||
|
||||
[ -r "$KEY" ] || {
|
||||
echo "container-repo.sh: signing key not mounted at $KEY" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
chown builder:builder /void-packages
|
||||
|
||||
echo "== build every template in order: $PKGS"
|
||||
cd /void-packages
|
||||
# shellcheck disable=SC2086
|
||||
for PKG in $PKGS; do
|
||||
echo "== xlint $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'"
|
||||
echo "== xbps-src pkg $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'"
|
||||
done
|
||||
|
||||
echo "== collect built packages"
|
||||
mkdir -p "$REPO" "$KEYS"
|
||||
# shellcheck disable=SC2086
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || {
|
||||
echo "FAIL: $PKG (built package missing from hostdir/binpkgs)" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "collected: $(basename "$PKGFILE")"
|
||||
cp "$PKGFILE" "$REPO/"
|
||||
done
|
||||
|
||||
echo "== index the full repository"
|
||||
# shellcheck disable=SC2086
|
||||
xbps-rindex -a "$REPO"/*.x86_64.xbps
|
||||
|
||||
echo "== sign every binary package"
|
||||
xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"/*.x86_64.xbps
|
||||
|
||||
echo "== sign the repository metadata"
|
||||
xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"
|
||||
|
||||
echo "== prove an unsigned install is refused"
|
||||
set +e
|
||||
PROBE=$(xbps-install -r /tmp/keyprobe --repository="$REPO" -Sy voculinux </dev/null 2>&1)
|
||||
PROBE_RC=$?
|
||||
set -e
|
||||
if [ "$PROBE_RC" -eq 0 ] || ! printf '%s' "$PROBE" | grep -q "RSA signed"; then
|
||||
echo "FAIL: unsigned install was not refused — signatures are not enforced" >&2
|
||||
printf '%s\n' "$PROBE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "unsigned install refused as required"
|
||||
|
||||
FP=$(printf '%s' "$PROBE" | grep -i Fingerprint | awk '{print $2}' | head -n1)
|
||||
[ -n "$FP" ] || {
|
||||
echo "FAIL: could not read the key fingerprint from the refusal" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "key fingerprint: $FP"
|
||||
|
||||
echo "== stage the client key"
|
||||
openssl rsa -in "$KEY" -pubout -out /tmp/repo-pub.pem 2>/dev/null
|
||||
PUB64=$(base64 -w0 /tmp/repo-pub.pem)
|
||||
# XML-escape the signer identity: xbps writes </> itself on import
|
||||
# and rejects a plist carrying raw angle brackets.
|
||||
ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g')
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
printf '%s\n' '<plist version="1.0">' '<dict>' ' <key>public-key</key>'
|
||||
printf ' <data>%s</data>\n' "$PUB64"
|
||||
printf '%s\n' ' <key>public-key-size</key>' ' <integer>4096</integer>' ' <key>signature-by</key>'
|
||||
printf ' <string>%s</string>\n' "$ESCAPED_SIGNEDBY"
|
||||
printf '%s\n' '</dict>' '</plist>'
|
||||
} > "$KEYS/$FP.plist"
|
||||
{
|
||||
printf 'signed-by: %s\n' "$SIGNEDBY"
|
||||
printf 'fingerprint: %s\n' "$FP"
|
||||
} > "$KEYS/FINGERPRINT"
|
||||
echo "staged: keys/$FP.plist"
|
||||
|
||||
echo "== verify: install the app from the signed repo in a clean rootdir"
|
||||
VERIFY=/tmp/verify-install
|
||||
mkdir -p "$VERIFY/var/db/xbps/keys"
|
||||
# The app's full dependency tree resolves from the official remote, so the
|
||||
# verify rootdir trusts the official keys too; our repo stays enforced
|
||||
# because its packages verify only against the staged key below.
|
||||
cp /var/db/xbps/keys/*.plist "$VERIFY/var/db/xbps/keys/"
|
||||
cp "$KEYS/$FP.plist" "$VERIFY/var/db/xbps/keys/"
|
||||
xbps-install -y -r "$VERIFY" \
|
||||
--repository="$REPO" \
|
||||
--repository=https://repo-default.voidlinux.org/current \
|
||||
-Sy voculinux
|
||||
xbps-query -r "$VERIFY" -l | grep -q "^ii voculinux-" || {
|
||||
echo "FAIL: voculinux not installed in the verify rootdir" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "verified: voculinux installs from the signed repository"
|
||||
|
||||
ls "$REPO"
|
||||
echo "SIGNED REPO OK: fingerprint $FP"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
# Renders the voculinux template for one release inside a void-packages build
|
||||
# tree (ticket #27, spec #16 "Release pipeline and upstream intake").
|
||||
#
|
||||
# The committed template is never edited by a build. This script runs against
|
||||
# the container's copy of srcpkgs and does three things, each refused unless it
|
||||
# lands exactly once:
|
||||
#
|
||||
# 1. pins version, _tag and checksum to the release being built;
|
||||
# 2. checks the sdist's digest is the checksum it just pinned;
|
||||
# 3. places the sdist where xbps-src looks for a distfile of this package
|
||||
# (hostdir/sources/<pkgname>-<version>/<basename>), so xbps-src verifies
|
||||
# the local file and never downloads it.
|
||||
#
|
||||
# Run as root, by container-release.sh (release) and container-main.sh (smoke).
|
||||
# Inputs arrive as environment variables from build-release.sh:
|
||||
# VOID_PACKAGES void-packages checkout (default /void-packages)
|
||||
# SDIST_DIR directory holding the sdist (read only)
|
||||
# XBPS_VERSION xbps spelling of the release, e.g. 0.18.5 or 0.19.0beta
|
||||
# TAG the release tag, e.g. v0.19.0-beta
|
||||
# SDIST_NAME distfile basename, voculinux-<XBPS_VERSION>.tar.gz
|
||||
# SDIST_SHA256 sha256 of $SDIST_DIR/$SDIST_NAME
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
echo "render-voculinux.sh: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
VP=${VOID_PACKAGES:-/void-packages}
|
||||
TEMPLATE="$VP/srcpkgs/voculinux/template"
|
||||
|
||||
for name in SDIST_DIR XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do
|
||||
eval "value=\${$name:-}"
|
||||
[ -n "$value" ] || fail "$name is not set"
|
||||
done
|
||||
for name in XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do
|
||||
eval "value=\${$name}"
|
||||
case "$value" in
|
||||
*[!A-Za-z0-9._-]*) fail "$name has characters outside [A-Za-z0-9._-]: $value" ;;
|
||||
esac
|
||||
done
|
||||
[ -f "$TEMPLATE" ] || fail "no voculinux template at $TEMPLATE"
|
||||
[ -s "$SDIST_DIR/$SDIST_NAME" ] || fail "sdist is missing: $SDIST_DIR/$SDIST_NAME"
|
||||
|
||||
digest=$(sha256sum "$SDIST_DIR/$SDIST_NAME" | cut -d' ' -f1)
|
||||
[ "$digest" = "$SDIST_SHA256" ] || fail "sdist digest $digest is not the expected $SDIST_SHA256"
|
||||
|
||||
# set_line KEY VALUE: the template must hold exactly one KEY= line, and the
|
||||
# rewritten line must read back exactly as asked.
|
||||
set_line() {
|
||||
key=$1
|
||||
value=$2
|
||||
count=$(grep -c "^$key=" "$TEMPLATE" || true)
|
||||
[ "$count" = 1 ] || fail "template must hold exactly one $key= line, found $count"
|
||||
sed -i "s|^$key=.*|$key=$value|" "$TEMPLATE"
|
||||
grep -qxF "$key=$value" "$TEMPLATE" || fail "could not pin $key=$value in the build tree"
|
||||
}
|
||||
|
||||
set_line version "$XBPS_VERSION"
|
||||
set_line _tag "$TAG"
|
||||
set_line checksum "$SDIST_SHA256"
|
||||
|
||||
DEST="$VP/hostdir/sources/voculinux-$XBPS_VERSION"
|
||||
mkdir -p "$DEST"
|
||||
cp "$SDIST_DIR/$SDIST_NAME" "$DEST/$SDIST_NAME"
|
||||
[ "$(sha256sum "$DEST/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
|
||||
fail "the copied distfile does not match $SDIST_SHA256"
|
||||
|
||||
echo "render-voculinux.sh: pinned version=$XBPS_VERSION _tag=$TAG checksum=$SDIST_SHA256"
|
||||
echo "render-voculinux.sh: distfile staged at $DEST/$SDIST_NAME"
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Signed xbps repository pipeline (ticket #29): build every template in
|
||||
# smoke-gate order, index the full repository, and sign every binary
|
||||
# package plus the repository metadata with the RSA4096 repository key.
|
||||
# Runs in the pinned Void container via podman, never Docker; no host
|
||||
# Void install needed.
|
||||
#
|
||||
# Usage: XBPS_SIGNING_KEY_FILE=/path/to/key.pem packaging/void/repo.sh
|
||||
# or: XBPS_SIGNING_KEY_FILE=/path/to/key.pem just void-repo
|
||||
#
|
||||
# Options (all optional):
|
||||
# --key-file PATH repository private key (default: $XBPS_SIGNING_KEY_FILE)
|
||||
# --out-dir DIR stage the signed repo here (default: publish/xbps)
|
||||
# --signed-by "N <m>" signer identity (default: Voculinux <xbps@voculinux.com>)
|
||||
#
|
||||
# The private key arrives as a file so CI can write the XBPS_SIGNING_KEY
|
||||
# secret to disk with newlines intact:
|
||||
#
|
||||
# printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
|
||||
# XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh
|
||||
#
|
||||
# Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before
|
||||
# any container runs, so an unsigned repository can never publish.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
|
||||
KEY_FILE=${XBPS_SIGNING_KEY_FILE:-}
|
||||
OUT_DIR="$REPO_ROOT/publish/xbps"
|
||||
SIGNEDBY="Voculinux <xbps@voculinux.com>"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--key-file) KEY_FILE=${2:?usage: repo.sh --key-file PATH} ; shift 2 ;;
|
||||
--out-dir) OUT_DIR=${2:?usage: repo.sh --out-dir DIR} ; shift 2 ;;
|
||||
--signed-by) SIGNEDBY=${2:?usage: repo.sh --signed-by "Name <mail>"} ; shift 2 ;;
|
||||
-*) echo "repo.sh: unknown option: $1" >&2 ; exit 1 ;;
|
||||
*) echo "repo.sh: unexpected argument: $1" >&2 ; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Fail closed before any container runs: no key, no repo, no publish.
|
||||
[ -n "$KEY_FILE" ] || {
|
||||
echo "repo.sh: no signing key: pass --key-file or set XBPS_SIGNING_KEY_FILE" >&2
|
||||
echo "repo.sh: CI writes the XBPS_SIGNING_KEY secret to a file first:" >&2
|
||||
echo "repo.sh: printf '%s' \"\$XBPS_SIGNING_KEY\" > /tmp/xbps-sign-key.pem" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] || {
|
||||
echo "repo.sh: signing key missing or empty: $KEY_FILE" >&2
|
||||
echo "repo.sh: refusing to publish an unsigned repository" >&2
|
||||
exit 1
|
||||
}
|
||||
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 || {
|
||||
echo "repo.sh: not a valid RSA private key: $KEY_FILE" >&2
|
||||
echo "repo.sh: refusing to publish an unsigned repository" >&2
|
||||
exit 1
|
||||
}
|
||||
openssl rsa -in "$KEY_FILE" -text -noout 2>/dev/null | head -1 | grep -q "4096 bit" || {
|
||||
echo "repo.sh: signing key is not RSA4096: $KEY_FILE" >&2
|
||||
echo "repo.sh: generate one with: openssl genrsa -out xbps-sign-key.pem 4096" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
|
||||
echo "repo.sh: pins missing from tool_checksums.txt" >&2
|
||||
exit 1
|
||||
}
|
||||
# One dependency order for the whole track: the signed repo builds the
|
||||
# same set in the same order as the smoke gate (the app last), so a
|
||||
# renamed or added template cannot silently drop out of either.
|
||||
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
|
||||
[ -n "$PKGS" ] || {
|
||||
echo "repo.sh: could not read the package order from smoke.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "Pulling pinned base image: $BASE_IMAGE"
|
||||
podman pull "$BASE_IMAGE"
|
||||
|
||||
podman run --rm \
|
||||
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-repo.sh:/mnt/container-repo.sh:ro" \
|
||||
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
|
||||
-v "$OUT_DIR:/mnt/out" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "SIGNEDBY=$SIGNEDBY" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-repo.sh
|
||||
echo "OK: signed repository staged in $OUT_DIR"
|
||||
+11
-22
@@ -1,35 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Smoke gate for all Void templates (ticket #28): lint, build, install
|
||||
# and version/import smoke for every template, one command, from a clean
|
||||
# checkout. Runs under podman, never Docker; no host Void install needed.
|
||||
# Smoke gate for all Void templates (ticket #28): one command builds every
|
||||
# template in dependency order, signs the result with a throwaway key, installs
|
||||
# voculinux from that repository in a clean Void image and smokes it. The gate
|
||||
# runs the same code path as a release (packaging/void/build-release.sh), so a
|
||||
# gate that passes is a release that would build.
|
||||
#
|
||||
# Usage: packaging/void/smoke.sh
|
||||
# or: just void-smoke
|
||||
#
|
||||
# Builds every template under packaging/void/srcpkgs in dependency order
|
||||
# (leaves first, the app last — it resolves the others from the local
|
||||
# repository at install time) by delegating each package to build.sh,
|
||||
# which runs xlint, xbps-src, install and the payload/version/import
|
||||
# smoke in the pinned Void container. Fails fast naming the package; the
|
||||
# harness echoes each stage, so the output shows which package broke at
|
||||
# which stage.
|
||||
# Podman only, never Docker. Nothing is published, and the release key is not
|
||||
# used. Needs the same tools as a release: podman, openssl, git, python3.11,
|
||||
# and uv 0.12.x or 0.13.x (see packaging/void/README.md).
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
|
||||
# Dependency order: the app last. tests/test_void_smoke_gate.py fails if
|
||||
# this list drifts from packaging/void/srcpkgs or the app moves earlier.
|
||||
# Dependency order: the app last. tests/test_void_smoke_gate.py fails if this
|
||||
# list drifts from packaging/void/srcpkgs.
|
||||
PKGS="python3-PyAudio python3-pynput python3-pywhispercpp voculinux"
|
||||
|
||||
for PKG in $PKGS; do
|
||||
echo "== smoke $PKG"
|
||||
"$HERE/build.sh" "$PKG" || {
|
||||
echo "FAIL: $PKG (see the stage above: xlint, xbps-src, install, payload/version/import smoke)" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "OK: $PKG linted, built, installed and smoked"
|
||||
done
|
||||
|
||||
echo "SMOKE GATE PASSED: $PKGS"
|
||||
exec "$HERE/build-release.sh" smoke
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
pkgname=voculinux
|
||||
version=0.18.5
|
||||
revision=1
|
||||
# The release tag this template names: v + __version__, with the pre-release
|
||||
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
|
||||
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
|
||||
_tag=v0.18.5
|
||||
# The release sdist ships no setup.py (setuptools pyproject-only), so the
|
||||
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
|
||||
build_style=python3-pep517
|
||||
@@ -16,12 +20,12 @@ short_desc="Free, offline voice dictation for Linux"
|
||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||
license="AGPL-3.0-only"
|
||||
homepage="https://git.bongbetic.com/xavierk/voculinux"
|
||||
distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/v${version}/voculinux-${version}.tar.gz"
|
||||
# Pending: set to the sha256 of voculinux-${version}.tar.gz once the fork's
|
||||
# v${version} release exists (see packaging/void/README.md, "Checksum").
|
||||
# Until then xbps-src fails closed on this value; it never falls back to the
|
||||
# upstream archive.
|
||||
checksum=PENDING_FORK_RELEASE_SHA256
|
||||
distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/${_tag}/voculinux-${version}.tar.gz"
|
||||
# sha256 of that release sdist. Recorded here by the post-release pull request
|
||||
# after each release. Until the first fork release it is a placeholder that is
|
||||
# never a real digest: every release build replaces it, in its build tree only,
|
||||
# with the digest of the sdist it builds (packaging/void/render-voculinux.sh).
|
||||
checksum=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
post_install() {
|
||||
# The fork's sdist already names the desktop entry, icons and launchers
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Pinned build inputs for packaging/void/build.sh.
|
||||
# Pinned build inputs for packaging/void/build-release.sh (and smoke.sh, which calls it).
|
||||
#
|
||||
# Same contract as packaging/appimage/tool_checksums.txt and
|
||||
# packaging/native/tool_checksums.txt: everything the package build
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""xbps repository-key helper for packaging/void/build-release.sh.
|
||||
|
||||
xbps-fingerprint.py fingerprint PEM
|
||||
print the xbps fingerprint of an RSA public key (PEM): the MD5 of the
|
||||
ssh-rsa blob of the key, the identifier libxbps calls xbps_pubkey2fp.
|
||||
|
||||
xbps-fingerprint.py plist PEM SIGNER OUT
|
||||
write the client key plist xbps expects for that public key. Used only
|
||||
for a throwaway smoke key; the release publishes the committed
|
||||
repository-key.plist byte for byte.
|
||||
|
||||
Only the public key is ever read here. Run with -I: the interpreter must not
|
||||
pick up modules from the directory a downloaded file sits in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import plistlib
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _rsa_numbers(pem: Path) -> tuple[int, int]:
|
||||
"""Modulus and exponent of a PEM public key, read through openssl."""
|
||||
text = subprocess.run(
|
||||
["openssl", "rsa", "-pubin", "-in", str(pem), "-noout", "-text"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
modulus_hex: list[str] = []
|
||||
exponent = None
|
||||
in_modulus = False
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("Modulus"):
|
||||
in_modulus = True
|
||||
continue
|
||||
if stripped.startswith("Exponent"):
|
||||
in_modulus = False
|
||||
exponent = int(stripped.split()[1])
|
||||
continue
|
||||
if in_modulus and re.fullmatch(r"[0-9a-f:]+", stripped):
|
||||
modulus_hex.append(stripped.replace(":", ""))
|
||||
if exponent is None or not modulus_hex:
|
||||
raise SystemExit(f"xbps-fingerprint.py: not an RSA public key: {pem}")
|
||||
return int("".join(modulus_hex), 16), exponent
|
||||
|
||||
|
||||
def _mpint(value: int) -> bytes:
|
||||
"""An SSH mpint: big-endian, with a leading zero when the top bit is set."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
if raw[0] & 0x80:
|
||||
raw = b"\x00" + raw
|
||||
return struct.pack(">I", len(raw)) + raw
|
||||
|
||||
|
||||
def fingerprint(pem: Path) -> str:
|
||||
"""Colon-separated lowercase MD5 of the ssh-rsa blob of the key."""
|
||||
modulus, exponent = _rsa_numbers(pem)
|
||||
blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus)
|
||||
digest = hashlib.md5(blob, usedforsecurity=False).hexdigest()
|
||||
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
|
||||
|
||||
|
||||
def write_plist(pem: Path, signer: str, out: Path) -> None:
|
||||
"""Write the client key plist xbps reads from /var/db/xbps/keys."""
|
||||
modulus, _ = _rsa_numbers(pem)
|
||||
entry = {
|
||||
"public-key": pem.read_bytes(),
|
||||
"public-key-size": modulus.bit_length(),
|
||||
"signature-by": signer,
|
||||
}
|
||||
with out.open("wb") as handle:
|
||||
plistlib.dump(entry, handle, fmt=plistlib.FMT_XML)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) == 2 and argv[0] == "fingerprint":
|
||||
print(fingerprint(Path(argv[1])))
|
||||
return 0
|
||||
if len(argv) == 4 and argv[0] == "plist":
|
||||
write_plist(Path(argv[1]), argv[2], Path(argv[3]))
|
||||
return 0
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user