#!/bin/bash # host-repo.sh — publish a git checkout as a bare repo on upspin, ready to be # browsed, cloned, and pushed through gitweb (https://git.cosmocr.at). # # Usage: # scripts/host-repo.sh [--public | --access FILE] [--config FILE] \ # [--owner-config FILE] # # an existing git working copy (committed history is # published; the working tree and remotes are not) # destination, e.g. rpg137@gmail.com/myrepo.git # --public grant read,list to all (anonymous browse + clone); # default is private: only the owner and the service # --access FILE use FILE as the repo's Access file instead of the # generated one (mutually exclusive with --public) # --config FILE upspin config to use (default $HOME/upspin/config) # --owner-config FILE # config of the destination tree's OWNER, used only to put # the Access file. Needed when hosting into another user's # tree: upspin lets only the owner write Access files, while # the content upload runs as the (granted) --config user. # # What it does, and why: # - Makes a fresh `git clone --bare` of the checkout (single packfile + # packed-refs — the fewest upspin files), drops hooks/description and the # origin remote (which would leak the local path), and sanity-checks HEAD. # - Puts the Access file FIRST, then uploads the repo content. With ee # packing, each file's decryption key is wrapped for the readers granted # by the governing Access file *at write time* — so the grants (the # service, and `all` for --public) must be in place before the upload, or # every file would need an `upspin share -fix` afterwards to be readable. # - Uploads HEAD last: gitweb treats a directory as a repo once HEAD and # objects/ both exist, so holding HEAD back keeps the repo from being # served half-copied. # - The Access file grants `*` (all rights) to the tree owner; to # git@cosmocr.at unless it is itself the owner (the service reads the repo # for web/git clients and `git push` write-back runs as it); to the Admins # group; and, with --owner-config, to the uploading user, who is not the # owner yet still writes the content. Who may *push* is whoever holds the # upspin `write` right here (scripts/update-access.sh edits and re-shares). # # Requires: git, upspin (with the destination tree owned by the configured # upspin user — only the owner may put Access files). set -euo pipefail die() { echo "host-repo.sh: $*" >&2; exit 1; } PUBLIC=0 CONFIG="${HOME}/upspin/config" OWNERCONFIG="" ACCESSFILE="" ARGS=() while [ $# -gt 0 ]; do case "$1" in --public) PUBLIC=1 ;; --access) shift; ACCESSFILE="${1:?--access needs a file}" ;; --config) shift; CONFIG="${1:?--config needs a file}" ;; --owner-config) shift; OWNERCONFIG="${1:?--owner-config needs a file}" ;; -h|--help) awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "$0"; exit 0 ;; -*) die "unknown flag $1" ;; *) ARGS+=("$1") ;; esac shift done [ "${#ARGS[@]}" -eq 2 ] || die "usage: host-repo.sh [--public | --access FILE] [--config FILE] [--owner-config FILE] " CHECKOUT="${ARGS[0]}" DEST="${ARGS[1]%/}" [ -z "$ACCESSFILE" ] || [ "$PUBLIC" = 0 ] || die "--access and --public are mutually exclusive (put read/list: all in the file)" [ -z "$ACCESSFILE" ] || [ -s "$ACCESSFILE" ] || die "--access file missing or empty: $ACCESSFILE" command -v git >/dev/null || die "git not found" command -v upspin >/dev/null || die "upspin not found" [ -f "$CONFIG" ] || die "upspin config not found: $CONFIG" # --- validate the source checkout --- git -C "$CHECKOUT" rev-parse --git-dir >/dev/null 2>&1 || die "$CHECKOUT is not a git checkout" git -C "$CHECKOUT" rev-parse -q --verify HEAD >/dev/null || die "$CHECKOUT has no commits" # --- validate the destination path --- OWNER="${DEST%%/*}" case "$OWNER" in *@*.*) ;; *) die "destination must start with an upspin user (user@domain/...): $DEST" ;; esac [ "$DEST" != "$OWNER" ] || die "destination must include a repo path under the user root" case "$DEST" in *.git) ;; *) echo "note: destination does not end in .git — works, but the convention helps clients" >&2 ;; esac ME=$(sed -n 's/^username: *//p' "$CONFIG" | head -1) if [ -n "$OWNERCONFIG" ]; then [ -f "$OWNERCONFIG" ] || die "owner config not found: $OWNERCONFIG" OC_USER=$(sed -n 's/^username: *//p' "$OWNERCONFIG" | head -1) [ "$OC_USER" = "$OWNER" ] || die "--owner-config user is $OC_USER but the destination tree is owned by $OWNER" elif [ "$ME" != "$OWNER" ]; then echo "warning: destination tree is owned by $OWNER but the config user is $ME;" >&2 echo " the Access put will fail unless run as the owner (see --owner-config)" >&2 fi if upspin -config="$CONFIG" ls "$DEST" >/dev/null 2>&1; then die "$DEST already exists — refusing to overwrite" fi # --- stage a clean bare repo --- TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT BASE=$(basename "$DEST") STAGE="$TMP/$BASE" # cp -R below copies the dir itself, so its name is final git clone --quiet --bare "$CHECKOUT" "$STAGE" git -C "$STAGE" config --remove-section remote.origin 2>/dev/null || true git -C "$STAGE" pack-refs --all rm -rf "$STAGE/hooks" "$STAGE/description" # HEAD must be a symref to a branch that exists, or clients see an empty repo. HEADREF=$(git -C "$STAGE" symbolic-ref -q HEAD || true) if [ -z "$HEADREF" ] || ! git -C "$STAGE" show-ref -q --verify "$HEADREF"; then FIRST=$(git -C "$STAGE" for-each-ref --format='%(refname)' refs/heads | head -1) [ -n "$FIRST" ] || die "staged repo has no branches" git -C "$STAGE" symbolic-ref HEAD "$FIRST" echo "note: HEAD pointed at a missing branch; reset to $FIRST" >&2 fi # --- upspin with config --- UP() { upspin -config="$CONFIG" "$@"; } # --- upload: parents, Access first, content, HEAD last --- if [ "$(dirname "$DEST")" != "$OWNER" ]; then # Create intermediate directories one level at a time (no mkdir -p). REL=$(dirname "${DEST#"$OWNER"/}") P="$OWNER" IFS='/' read -ra SEGS <<< "$REL" for seg in "${SEGS[@]}"; do P="$P/$seg" UP mkdir "$P" 2>/dev/null || true done fi UP mkdir "$DEST" # Access before content, so ee-packed uploads wrap keys for these readers. # Only the tree owner may put Access files, hence the --owner-config escape. ACCESS="$TMP/Access" if [ -n "$ACCESSFILE" ]; then cp "$ACCESSFILE" "$ACCESS" else # The "*" list, with nobody named twice: the owner; the service, unless it is # the owner; the Admins group; and, when --owner-config is in play, the # uploading user $ME, who is not the owner but still writes the content. STAR="$OWNER" [ "$OWNER" = "git@cosmocr.at" ] || STAR="$STAR, git@cosmocr.at" STAR="$STAR, upspin@cosmocr.at/Group/Admins" if [ -n "$OWNERCONFIG" ] && [ "$ME" != "$OWNER" ] && [ "$ME" != "git@cosmocr.at" ]; then STAR="$STAR, $ME" fi { echo "*: $STAR" if [ "$PUBLIC" = 1 ]; then echo "read: all" echo "list: all" echo "write: upspin@cosmocr.at/Group/Writers" fi } > "$ACCESS" fi if [ -n "$OWNERCONFIG" ]; then upspin -config="$OWNERCONFIG" cp "$ACCESS" "$DEST/Access" else UP cp "$ACCESS" "$DEST/Access" fi echo "==> uploading $(du -sh "$STAGE" | cut -f1) to $DEST" # Everything except HEAD, then HEAD: the repo only becomes servable once # HEAD lands. `upspin cp -R dir dst/` copies dir *into* dst by basename. for entry in "$STAGE"/*; do name=$(basename "$entry") [ "$name" = "HEAD" ] && continue if [ -d "$entry" ]; then UP cp -R "$entry" "$DEST/" else UP cp "$entry" "$DEST/" fi done UP cp "$STAGE/HEAD" "$DEST/" # --- verify --- UP ls "$DEST/HEAD" >/dev/null || die "verify failed: $DEST/HEAD not readable" URL="https://git.cosmocr.at/$DEST" echo "==> hosted at $DEST" if grep -qE '^\s*read\s*[:,].*\ball\b' "$ACCESS"; then echo " browse/clone (anonymous): $URL" else echo " private: browse/clone/push need credentials (vault SSO via" echo " git-credential-cosmocr, or a PAT in $OWNER/gitweb/tokens)" echo " clone: git clone $URL" fi echo " push access: users with 'write' in $DEST/Access"