debian/publish.sh
Rama master · réplica de lectura del repositorio original.
#!/bin/bash
#
# Publish .deb files to an S3-hosted apt repository.
#
# Usage: publish-deb.sh <deb-file-or-directory> [<deb-file-or-directory> ...]
#
# Environment variables:
# GPG_PRIVATE_KEY - Armored GPG private key (imported if set)
# GPG_KEY_ID - GPG key ID for signing
# S3_ACCESS_KEY - S3 access key
# S3_SECRET_KEY - S3 secret key
# S3_ENDPOINT - S3 endpoint (default: https://nyc3.digitaloceanspaces.com)
# S3_BUCKET - S3 bucket name (default: start9-debs)
# SUITE - Apt suite name (default: stable). Each suite owns a
# separate pool, so suites never evict each other.
# COMPONENT - Apt component name (default: main)
# APT_KEYRING - Public keyring that verifies the published suite
# (default: the suite's keyring under apt/)
set -e
if [ $# -eq 0 ]; then
echo "Usage: $0 <deb-file-or-directory> [...]" >&2
exit 1
fi
BUCKET="${S3_BUCKET:-start9-debs}"
ENDPOINT="${S3_ENDPOINT:-https://nyc3.digitaloceanspaces.com}"
GPG_KEY_ID="${GPG_KEY_ID:-5259ADFC2D63C217}"
SUITE="${SUITE:-stable}"
COMPONENT="${COMPONENT:-main}"
REPO_DIR="$(mktemp -d)"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
case "$SUITE" in
stable) DEFAULT_KEYRING="$REPO_ROOT/apt/start9.gpg" ;;
alpha) DEFAULT_KEYRING="$REPO_ROOT/apt/start9-alpha.gpg" ;;
*) DEFAULT_KEYRING="" ;;
esac
KEYRING="${APT_KEYRING:-$DEFAULT_KEYRING}"
if [ -z "$KEYRING" ] || [ ! -f "$KEYRING" ]; then
echo "No keyring to verify the ${SUITE} suite: '${KEYRING}'. Set APT_KEYRING." >&2
exit 1
fi
# Every suite owns its own pool, so publishing to one can never index or evict
# another's .deb — the eviction below, the Packages scan, and both syncs are all
# scoped to POOL_ROOT. The roots must be **siblings**, not nested: `stable`
# scans its whole root, so an `alpha` pool living under `pool/` would show up in
# `dists/stable/.../Packages` as `Filename: pool/alpha/...`.
#
# `stable` keeps the historical `pool/` path because the published repo is
# already laid out that way — moving it would break `stable` for the window
# between the delete and the re-upload.
if [ "$SUITE" = stable ]; then
POOL_ROOT="pool"
else
POOL_ROOT="pool-${SUITE}"
fi
cleanup() {
rm -rf "$REPO_DIR"
}
trap cleanup EXIT
# Import GPG key if provided
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --import 2>/dev/null
fi
# Configure s3cmd
if [ -n "$S3_ACCESS_KEY" ] && [ -n "$S3_SECRET_KEY" ]; then
S3CMD_CONFIG="$(mktemp)"
cat > "$S3CMD_CONFIG" <<EOF
[default]
access_key = ${S3_ACCESS_KEY}
secret_key = ${S3_SECRET_KEY}
host_base = $(echo "$ENDPOINT" | sed 's|https://||')
host_bucket = %(bucket)s.$(echo "$ENDPOINT" | sed 's|https://||')
use_https = True
EOF
s3() {
s3cmd -c "$S3CMD_CONFIG" "$@"
}
else
# Fall back to default ~/.s3cfg
S3CMD_CONFIG=""
s3() {
s3cmd "$@"
}
fi
# Sync down only what this suite owns — its pool and its dists/ directory.
# Everything below (the eviction, the Packages scan, the --delete-removed upload)
# then operates on a tree that physically cannot contain another suite's files,
# so publishing `alpha` from CI can neither index nor delete anything in
# `stable`. Trust is already separated by the per-suite Release signature; this
# separates write access too.
#
# A failed or partial download must NOT be swallowed: the upload below prunes
# whatever is missing from this mirror, so proceeding on half a mirror would
# delete the other half of the suite. An empty prefix is the one benign case
# (first publish), and it is detected explicitly rather than inferred from an
# error.
sync_down() {
local prefix="$1" dest="$2" listing
mkdir -p "$dest"
listing=$(s3 ls --recursive "s3://${BUCKET}/${prefix}")
if [ -z "$listing" ]; then
echo " s3://${BUCKET}/${prefix} is empty — first publish into it."
return 0
fi
s3 sync --no-mime-magic "s3://${BUCKET}/${prefix}" "$dest"
}
echo "Syncing the ${SUITE} suite from s3://${BUCKET}/ ..."
sync_down "${POOL_ROOT}/" "$REPO_DIR/${POOL_ROOT}/"
sync_down "dists/${SUITE}/" "$REPO_DIR/dists/${SUITE}/"
# Collect all .deb files from arguments
DEB_FILES=()
for arg in "$@"; do
if [ -d "$arg" ]; then
# `gh run download` leaves a directory per artifact, and the product
# workflows name theirs `<product>_<arch>.deb`.
while IFS= read -r -d '' f; do
DEB_FILES+=("$f")
done < <(find "$arg" -type f -name '*.deb' -print0)
elif [ -f "$arg" ]; then
DEB_FILES+=("$arg")
else
echo "Warning: $arg is not a file or directory, skipping" >&2
fi
done
if [ ${#DEB_FILES[@]} -eq 0 ]; then
echo "No .deb files found" >&2
exit 1
fi
sha256_of() {
sha256sum "$1" | cut -d' ' -f1
}
# Print "<sha256> <path>" for every file already in the pool.
pool_contents() {
local file
while IFS= read -r -d '' file; do
printf '%s %s\n' "$(sha256_of "$REPO_DIR/$file")" "$file"
done < <(cd "$REPO_DIR" && find "$POOL_ROOT" -type f -print0)
}
# Bucket contents are untrusted. Refuses any pool file the suite's signed index
# does not commit to, and any index the signed Release does not commit to.
verify_mirror() {
local dist="$REPO_DIR/dists/${SUITE}" work contents hash size rel_path unknown
work="$(mktemp -d)"
contents="$(pool_contents | LC_ALL=C sort)"
if [ ! -f "$dist/InRelease" ]; then
rm -rf "$work"
if [ -n "$contents" ]; then
echo "Refusing to publish: ${POOL_ROOT}/ holds files but ${SUITE} has no InRelease:" >&2
echo "$contents" >&2
exit 1
fi
return 0
fi
if ! "$REPO_ROOT/debian/verify-release.sh" "$KEYRING" "$dist/InRelease" "$work/Release" 2> "$work/gpg.err"; then
echo "Refusing to publish: the ${SUITE} InRelease is not signed by ${KEYRING}" >&2
sed 's/^/ /' "$work/gpg.err" >&2
rm -rf "$work"
exit 1
fi
: > "$work/indexed"
while read -r hash size rel_path; do
if [ "$(sha256_of "$dist/$rel_path" 2> /dev/null)" != "$hash" ]; then
echo "Refusing to publish: ${rel_path} does not match the signed Release" >&2
rm -rf "$work"
exit 1
fi
awk '
/^$/ { if (f != "") print s " " f; f = ""; s = "" }
/^Filename:/ { f = $2 }
/^SHA256:/ { s = $2 }
END { if (f != "") print s " " f }
' "$dist/$rel_path" >> "$work/indexed"
done < <(awk -v c="$COMPONENT" '
/^SHA256:/ { in_sha = 1; next }
/^[^ ]/ { in_sha = 0 }
in_sha && $3 ~ ("^" c "/binary-[^/]+/Packages$")
' "$work/Release")
unknown="$(LC_ALL=C comm -23 <(echo "$contents" | sed '/^$/d') <(LC_ALL=C sort -u "$work/indexed"))"
rm -rf "$work"
if [ -n "$unknown" ]; then
echo "Refusing to publish: ${POOL_ROOT}/ holds files the signed ${SUITE} index does not list:" >&2
echo "$unknown" >&2
exit 1
fi
echo "Verified ${POOL_ROOT}/ against the signed ${SUITE} index"
}
# Replacing a package's own files first lets a retry finish an interrupted publish.
for deb in "${DEB_FILES[@]}"; do
PKG_NAME="$(dpkg-deb --field "$deb" Package)"
PKG_ARCH="$(dpkg-deb --field "$deb" Architecture)"
POOL_DIR="$REPO_DIR/${POOL_ROOT}/${COMPONENT}/${PKG_NAME:0:1}/${PKG_NAME}"
for old in "$POOL_DIR"/${PKG_NAME}_*_${PKG_ARCH}.deb; do
[ -f "$old" ] && rm -v "$old"
done
done
verify_mirror
# Release hashes whatever this directory holds, so nothing downloaded may remain.
rm -rf "${REPO_DIR:?}/dists/${SUITE}"
for deb in "${DEB_FILES[@]}"; do
PKG_NAME="$(dpkg-deb --field "$deb" Package)"
POOL_DIR="$REPO_DIR/${POOL_ROOT}/${COMPONENT}/${PKG_NAME:0:1}/${PKG_NAME}"
mkdir -p "$POOL_DIR"
cp "$deb" "$POOL_DIR/"
dpkg-name -o "$POOL_DIR/$(basename "$deb")" 2>/dev/null || true
echo "Added: $(basename "$deb") -> ${POOL_ROOT}/${COMPONENT}/${PKG_NAME:0:1}/${PKG_NAME}/"
done
# Generate Packages indices for each architecture
for arch in amd64 arm64 riscv64; do
BINARY_DIR="$REPO_DIR/dists/${SUITE}/${COMPONENT}/binary-${arch}"
mkdir -p "$BINARY_DIR"
(
cd "$REPO_DIR"
dpkg-scanpackages --multiversion --arch "$arch" "$POOL_ROOT/" > "$BINARY_DIR/Packages"
gzip -k -f "$BINARY_DIR/Packages"
)
echo "Generated Packages index for ${arch}"
done
# Generate Release file
(
cd "$REPO_DIR/dists/${SUITE}"
apt-ftparchive release \
-o "APT::FTPArchive::Release::Origin=Start9" \
-o "APT::FTPArchive::Release::Label=Start9" \
-o "APT::FTPArchive::Release::Suite=${SUITE}" \
-o "APT::FTPArchive::Release::Codename=${SUITE}" \
-o "APT::FTPArchive::Release::Architectures=amd64 arm64 riscv64" \
-o "APT::FTPArchive::Release::Components=${COMPONENT}" \
. > Release
)
echo "Generated Release file"
# Sign if GPG key is available
if [ -n "$GPG_KEY_ID" ]; then
(
cd "$REPO_DIR/dists/${SUITE}"
gpg --default-key "$GPG_KEY_ID" --batch --yes --detach-sign -o Release.gpg Release
gpg --default-key "$GPG_KEY_ID" --batch --yes --clearsign -o InRelease Release
)
echo "Signed Release file with key ${GPG_KEY_ID}"
else
echo "Warning: GPG_KEY_ID not set, Release file is unsigned" >&2
fi
# Upload to S3, one prefix at a time so --delete-removed can only ever prune
# within this suite. --delete-after keeps that pruning from running before the
# new pool files land, so the Packages index published above never points at an
# object that has been deleted but not yet re-uploaded. The pool goes first for
# the same reason: the index must never be newer than what it indexes.
echo "Uploading the ${SUITE} suite to s3://${BUCKET}/ ..."
s3 sync --acl-public --no-mime-magic --delete-removed --delete-after \
"$REPO_DIR/${POOL_ROOT}/" "s3://${BUCKET}/${POOL_ROOT}/"
s3 sync --acl-public --no-mime-magic --delete-removed --delete-after \
"$REPO_DIR/dists/${SUITE}/" "s3://${BUCKET}/dists/${SUITE}/"
[ -n "$S3CMD_CONFIG" ] && rm -f "$S3CMD_CONFIG"
echo "Done."