git@cosmocr.at/gitweb.git · summary refs
host-repo.sh: build the Access `*` list instead of hardcoding it
commit 2d414e7b6fb33749c1be1ad92fe90c75b8a142fd
Patrick Gallagher <rpg137@gmail.com> · 2026-09-20 12:56 -0400
parent ab6f418f
The generated Access file named the owner and git@cosmocr.at unconditionally,
so hosting into the service's own tree produced a line that listed
git@cosmocr.at twice. Build the list up instead: the tree owner; the service,
unless it is itself the owner; the Admins group; and, when --owner-config is in
play, the uploading user, who is not the owner but still writes the content.

--public now also grants push to the Writers group. Together that makes the
generated file for a repo in the git@cosmocr.at tree byte-identical to the
live demo.git/Access.

--help printed a fixed line range that had fallen behind the header and cut off
mid-sentence. Print the leading comment block and stop at the first
non-comment line, so it stays correct as the header grows.
scripts/host-repo.sh
#!/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] <checkout-path> <upspin-path>
#
# <checkout-path> an existing git working copy (committed history is
# published; the working tree and remotes are not)
# <upspin-path> 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 owner and to
-# git@cosmocr.at: the service reads the repo on behalf of web/git clients,
-# and `git push` write-back happens as git@cosmocr.at, so it needs
-# create/write/delete. Who may *push* through gitweb is whoever holds the
-# upspin `write` right in this Access file — add collaborators there
-# (scripts/update-access.sh edits it and re-shares in one step).
+# - 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) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ -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-path> <upspin-path>"
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 "*: $OWNER, git@cosmocr.at"
+ 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"