publish.sh's overwrite guard (fdroid-repo-ci#6) is correct but implies a contract nothing states: a colliding filename with no versionCode in it means only the first publish into a prefix can ever succeed. Flutter's constant app-<flavor>-release.apk hit this in weblib-viewer (#29), and the failure only surfaces on the second publish with an error that reads like a bucket-state problem. Add the naming contract to the README, and append a hint to the refusal message when the colliding object's basename has no digits at all -- a pure text addition, never changing which publishes are refused. Closes fdroid-repo-ci#13. Co-authored-by: bit <bit@das-labor.org>
302 lines
14 KiB
Bash
Executable File
302 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Upload a repo/ tree -- signed APKs plus the signed index -- to an
|
|
# S3-compatible bucket.
|
|
#
|
|
# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
|
|
# bash publish.sh <repo-root>/repo <bucket> <endpoint-url> [<prefix>]
|
|
#
|
|
# Bucket, endpoint and prefix are all arguments, never baked in here: this
|
|
# repo does not know, and per fdroid-repo-ci#1 must never come to know, which
|
|
# bucket or host any consumer publishes to. Credentials come from the
|
|
# standard AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN
|
|
# environment variables that `aws` itself reads -- nothing here parses or
|
|
# forwards them as arguments.
|
|
#
|
|
# Region is required by the `aws` CLI even against a non-AWS S3-compatible
|
|
# backend that ignores it; set AWS_DEFAULT_REGION if the endpoint cares,
|
|
# otherwise the placeholder below is never actually consulted.
|
|
#
|
|
# ## Ordering (fdroid-repo-ci#5)
|
|
#
|
|
# An F-Droid repo is a signed index plus the files it names. A partial
|
|
# upload can leave them disagreeing in two ways: the index lands first and
|
|
# names files that still 404 (breaks the repo for every client until the
|
|
# upload finishes or is rolled back), or the files land first and the index
|
|
# is not yet updated (harmless -- the new build is merely not visible yet).
|
|
# Only the first is dangerous, and `aws s3 sync` walking a tree in its own
|
|
# order gives no protection against it. So this script uploads in two
|
|
# passes, unconditionally: every non-index file first, the index itself
|
|
# last. The repo is therefore always internally consistent -- before a
|
|
# publish it still describes the old state, after it describes the new one,
|
|
# and an interruption anywhere in pass 1 simply leaves it describing the old
|
|
# state a little longer, never a mixture.
|
|
#
|
|
# "The index" here is index-v1.json(.asc), index-v2.json, entry.json and
|
|
# entry.jar -- fdroidserver's actual top-level index/entry file names,
|
|
# matched only at the top level of the tree (a nested path like
|
|
# diff/<ts>.json is not "the index" and travels with the ordinary files;
|
|
# it is only ever reached by a client that already has last publish's index
|
|
# and is willing to look for it).
|
|
#
|
|
# The two passes are also separately invokable, with `--files-only` and
|
|
# `--index-only`. This is not a test seam -- it is real, useful on its own:
|
|
# an interrupted publish can resume at the index step without re-uploading
|
|
# every APK, and a caller can stage files now and publish the index later.
|
|
# It is also what makes the ordering guarantee testable *by construction*:
|
|
# run `--files-only`, assert the bucket has the files and not the index, run
|
|
# `--index-only`, assert the index appears -- no synchronization hook and no
|
|
# `eval` of anything env-supplied needed in a script that runs with a
|
|
# signing key and S3 credentials in its environment, in a public repo. (An
|
|
# earlier version of this script had exactly such a hook; it was removed
|
|
# once these flags made it unnecessary.) With neither flag, both passes run,
|
|
# files then index, which is the safe default and is unconditional -- there
|
|
# is no way to select "index then files" short of invoking `--index-only`
|
|
# by hand before ever running `--files-only`, which is the deliberate
|
|
# misuse the two flags exist to make possible for cases that want it
|
|
# (resuming, staging) while never being the default.
|
|
#
|
|
# ## Credentials are verified before anything is uploaded (fdroid-repo-ci#5)
|
|
#
|
|
# Checking that AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are merely *set* does
|
|
# not mean they are *usable* -- an unusable-but-present credential failing
|
|
# halfway through is exactly how a partial (and, per the ordering point
|
|
# above, potentially index-first) upload happens. So before pass 1 this
|
|
# script makes one real authenticated call (`head-bucket`) and aborts if it
|
|
# fails, rather than discovering that the credentials do not work partway
|
|
# through a sync.
|
|
#
|
|
# ## Refusing to overwrite an existing APK (fdroid-repo-ci#6)
|
|
#
|
|
# next-version-code.sh's clamp is monotonic against *observed* state, not
|
|
# monotonic by construction -- it does not close the race where two jobs
|
|
# start in the same minute, both read the same `published` value, both
|
|
# compute the same next versionCode, and both build and publish. The window
|
|
# is the whole build, not just the clock read, because the version code is
|
|
# baked into the APK before it exists. Nothing in this script can prevent
|
|
# that wasted build. What it CAN do -- and is required to do, fdroid-repo-ci#6
|
|
# -- is turn the collision from a silent, permanent wrong state (two
|
|
# different APKs claiming one versionCode, with clients pinned to whichever
|
|
# they happened to fetch first) into a loud failure of the second job, at
|
|
# the last moment when the information is finally reliable: immediately
|
|
# before that job would otherwise upload over the first job's APK. An
|
|
# F-Droid repo names an APK `<applicationId>_<versionCode>.apk`, so the
|
|
# collision this guards against is exactly a name collision -- every local
|
|
# *.apk file's destination key is checked with `head-object` before pass 1,
|
|
# and the whole publish aborts if any of them is already there. This runs
|
|
# for every *.apk regardless of whether it actually collides, because there
|
|
# is no cheaper way to know in advance which ones might.
|
|
#
|
|
# Note this also means retrying a publish that already got partway through
|
|
# uploading its own APKs (e.g. pass 1 died mid-sync from a network drop)
|
|
# will now itself be refused on retry, indistinguishable at the object level
|
|
# from a genuine concurrent collision. That is an accepted cost of the same
|
|
# trade: this check cannot tell "the same job resuming" from "a different
|
|
# job that computed the same versionCode" without hashing every object,
|
|
# which was judged not worth the added complexity here. Recovering from that
|
|
# case is a fresh publish with a new versionCode, or deleting the partial
|
|
# upload by hand.
|
|
#
|
|
# Uses `aws s3 sync`, not `cp --recursive`: re-publishing re-uploads only
|
|
# what changed. Does **not** pass `--delete` by default -- an F-Droid repo
|
|
# normally keeps every APK it has ever served (older index entries can still
|
|
# point at them), so silently pruning the remote on every publish is the
|
|
# wrong default. Pass `--delete` as an extra argument if a caller genuinely
|
|
# wants the remote pruned to match local; it is forwarded to *both* passes,
|
|
# which is safe because `aws s3 sync --delete` never deletes anything that
|
|
# was excluded by that pass's own --exclude/--include filters -- so the
|
|
# files-pass never touches an index object and the index-pass never touches
|
|
# a file object, regardless of --delete.
|
|
#
|
|
# `fdroid update` needs no network; this is the one script in this repo that
|
|
# does (fdroid-repo-ci#1), and is accordingly the one script whose happy path
|
|
# the test suite cannot exercise against a real bucket. `--dry-run` prints
|
|
# the command(s) this script would run for whichever pass(es) are selected,
|
|
# in order -- including the `with-nixpkgs.sh` pin -- and exits without
|
|
# touching the network at all: no credential check, no APK-existence check,
|
|
# no sync. That is what the test suite asserts against for command
|
|
# construction and ordering. (A PATH-stubbed `aws` cannot do this job: `nix
|
|
# shell` puts the packages it was asked for at the *front* of PATH, ahead of
|
|
# anything already there, so a stub earlier on the caller's PATH would
|
|
# simply be shadowed by the real `aws` from awscli2.) The credential check,
|
|
# the APK-existence check and the two-pass ordering are each proven
|
|
# separately: a real invocation against a closed local port for "fails on
|
|
# the network, and fails during the credential check specifically, before
|
|
# any upload is attempted"; a tiny local HEAD-only HTTP stub for "an
|
|
# existing APK is refused, and only an existing APK", offline; and the
|
|
# opt-in live round trip against a real bucket, run once as `--files-only`
|
|
# then `--index-only` to observe the ordering by construction, and once
|
|
# more as a real head-to-head APK collision.
|
|
#
|
|
# Pins awscli2 via with-nixpkgs.sh/nixpkgs-pin, next to this script.
|
|
set -euo pipefail
|
|
|
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
|
usage() {
|
|
cat >&2 <<'USAGE'
|
|
usage: publish.sh [--dry-run] [--files-only | --index-only] \
|
|
<local-repo-dir> <bucket> <endpoint-url> [<prefix>] [extra aws s3 sync args...]
|
|
|
|
With neither --files-only nor --index-only, both passes run: every
|
|
non-index file first, then the index -- the safe default, and the only way
|
|
to select "index before files" is to invoke --index-only by hand ahead of
|
|
--files-only, never the default. --files-only and --index-only are mutually
|
|
exclusive.
|
|
|
|
Credentials: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and optionally
|
|
AWS_SESSION_TOKEN) must already be exported -- never passed as arguments.
|
|
USAGE
|
|
}
|
|
|
|
dry_run=false
|
|
files_only=false
|
|
index_only=false
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--dry-run) dry_run=true; shift ;;
|
|
--files-only) files_only=true; shift ;;
|
|
--index-only) index_only=true; shift ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$files_only" = true ] && [ "$index_only" = true ]; then
|
|
echo "publish.sh: --files-only and --index-only are mutually exclusive" >&2
|
|
exit 2
|
|
fi
|
|
|
|
if [ "$#" -lt 3 ]; then
|
|
usage
|
|
exit 2
|
|
fi
|
|
|
|
local_dir=$1
|
|
bucket=$2
|
|
endpoint=$3
|
|
shift 3
|
|
|
|
prefix=""
|
|
if [ "$#" -gt 0 ] && [[ $1 != -* ]]; then
|
|
prefix=$1
|
|
shift
|
|
fi
|
|
|
|
extra_args=("$@")
|
|
|
|
[ -d "$local_dir" ] || { echo "publish.sh: no such directory: $local_dir" >&2; exit 2; }
|
|
: "${AWS_ACCESS_KEY_ID:?publish.sh: AWS_ACCESS_KEY_ID is not set}"
|
|
: "${AWS_SECRET_ACCESS_KEY:?publish.sh: AWS_SECRET_ACCESS_KEY is not set}"
|
|
|
|
dest="s3://$bucket"
|
|
if [ -n "$prefix" ]; then
|
|
dest="$dest/${prefix#/}"
|
|
fi
|
|
region="${AWS_DEFAULT_REGION:-us-east-1}"
|
|
|
|
# awscli2 2.23+ attaches a checksum algorithm to every PutObject by default.
|
|
# Garage rejects it outright -- measured against a real bucket:
|
|
#
|
|
# An error occurred (InvalidRequest) when calling the PutObject
|
|
# operation: Bad request: invalid checksum algorithm
|
|
#
|
|
# with aws-cli/2.34.24. Every upload fails, and it reads as a permissions
|
|
# problem until you notice that reads succeed and that writes fail the same
|
|
# way against a bucket the same credentials demonstrably can write.
|
|
#
|
|
# Set here rather than documented, because a caller should not have to know
|
|
# about a client-side default to publish to the object store this script
|
|
# exists for -- and leaving it to the environment means it works on one
|
|
# machine and not another. `when_required` keeps checksums where the protocol
|
|
# actually needs them, so this is narrower than switching them off.
|
|
export AWS_REQUEST_CHECKSUM_CALCULATION="${AWS_REQUEST_CHECKSUM_CALCULATION:-when_required}"
|
|
export AWS_RESPONSE_CHECKSUM_VALIDATION="${AWS_RESPONSE_CHECKSUM_VALIDATION:-when_required}"
|
|
|
|
aws_base=(bash "$here/with-nixpkgs.sh" awscli2 aws --endpoint-url "$endpoint" --region "$region")
|
|
|
|
# Top-level index/entry file names only -- see the "Ordering" comment above
|
|
# for why a nested path such as diff/<ts>.json is deliberately not matched.
|
|
index_exclude_filters=(--exclude "index*" --exclude "entry.*")
|
|
index_only_filters=(--exclude "*" --include "index*" --include "entry.*")
|
|
|
|
cmd_files=("${aws_base[@]}" s3 sync "$local_dir" "$dest"
|
|
"${index_exclude_filters[@]}" "${extra_args[@]}")
|
|
cmd_index=("${aws_base[@]}" s3 sync "$local_dir" "$dest"
|
|
"${index_only_filters[@]}" "${extra_args[@]}")
|
|
|
|
# Which pass(es) this invocation runs. Unconditional and exactly one of
|
|
# three shapes: both (the default), files alone, or index alone -- the
|
|
# mutual-exclusivity check above already ruled out both flags together.
|
|
run_files=true
|
|
run_index=true
|
|
[ "$index_only" = true ] && run_files=false
|
|
[ "$files_only" = true ] && run_index=false
|
|
|
|
if [ "$dry_run" = true ]; then
|
|
if [ "$run_files" = true ]; then
|
|
printf '%q ' "${cmd_files[@]}"
|
|
printf '\n'
|
|
fi
|
|
if [ "$run_index" = true ]; then
|
|
printf '%q ' "${cmd_index[@]}"
|
|
printf '\n'
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
echo "publish.sh: verifying credentials against $endpoint"
|
|
if ! verify_out=$("${aws_base[@]}" s3api head-bucket --bucket "$bucket" 2>&1); then
|
|
echo "publish.sh: credentials rejected (or bucket unreachable) -- nothing was uploaded:" >&2
|
|
echo "$verify_out" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$run_files" = true ]; then
|
|
# Refuse to overwrite an existing APK -- see the comment block above for
|
|
# why this exists and what it does and does not close. Every local *.apk
|
|
# is checked, regardless of nesting, since fdroidserver's binary-repo
|
|
# layout keeps them under repo/ directly but nothing here should assume
|
|
# that. Skipped entirely for a plain --index-only invocation: the index
|
|
# pass never uploads a *.apk (index_only_filters excludes everything but
|
|
# the index/entry names), so there is nothing this check could catch.
|
|
while IFS= read -r -d '' apk; do
|
|
rel=${apk#"$local_dir"/}
|
|
key="$rel"
|
|
if [ -n "$prefix" ]; then
|
|
key="${prefix#/}/$rel"
|
|
fi
|
|
if head_out=$("${aws_base[@]}" s3api head-object --bucket "$bucket" --key "$key" 2>&1); then
|
|
echo "publish.sh: refusing to publish -- $key already exists in s3://$bucket (would overwrite an existing APK, fdroid-repo-ci#6)" >&2
|
|
# Hint only, never a control-flow change (fdroid-repo-ci#13): a
|
|
# colliding filename with no digits at all cannot be carrying a
|
|
# versionCode, which is the single most common way to trip this
|
|
# guard on every publish after the first -- Flutter's
|
|
# app-<flavor>-release.apk being the concrete case that motivated
|
|
# this. A filename that does contain digits still gets refused with
|
|
# just the message above, exactly as before; it may yet be a genuine
|
|
# collision (fdroid-repo-ci#6) and this heuristic cannot tell.
|
|
base=$(basename "$key")
|
|
if [[ $base != *[0-9]* ]]; then
|
|
echo "publish.sh: $base does not appear to carry a versionCode -- F-Droid's convention is <applicationId>_<versionCode>.apk; a constant filename plus this guard means only the first publish into a prefix can ever succeed" >&2
|
|
fi
|
|
exit 1
|
|
elif echo "$head_out" | grep -qi '404\|not found\|NoSuchKey'; then
|
|
: # does not exist yet -- fine, this is the common case
|
|
else
|
|
echo "publish.sh: could not check whether $key already exists -- refusing to guess:" >&2
|
|
echo "$head_out" >&2
|
|
exit 1
|
|
fi
|
|
done < <(find "$local_dir" -type f -name '*.apk' -print0)
|
|
|
|
echo "publish.sh: pass 1/2: uploading files (index deferred) $local_dir -> $dest"
|
|
"${cmd_files[@]}"
|
|
fi
|
|
|
|
if [ "$run_index" = true ]; then
|
|
echo "publish.sh: pass 2/2: uploading the index $local_dir -> $dest"
|
|
"${cmd_index[@]}"
|
|
fi
|
|
|
|
echo "publish.sh: done"
|