git@cosmocr.at/gitweb.git · summary refs
scripts: add host-repo.sh and update-access.sh
commit ebb14439c1663b77eeaf2e5e1aa29260f08785fa
Patrick Gallagher <rpg137@gmail.com> · 2026-07-05 10:53 -0400
parent baba5e97
host-repo.sh publishes a git checkout as a bare repo on upspin for
serving through gitweb: a clean bare clone (single pack, no hooks or
origin remote), the Access file put first — with ee packing, file keys
are wrapped for the readers granted by the governing Access at write
time, so grants must precede content or every file needs a share -fix —
then the content, with HEAD last so gitweb never serves a half-copied
repo. --public grants read,list to all; --access supplies a custom
Access file; --owner-config puts the Access file as the destination
tree's owner (upspin's owner-only rule) when hosting into another
user's tree.

update-access.sh edits a hosted directory's Access file (in $EDITOR or
from --file) and then reconciles wrapped keys with `upspin share -fix
-r` so the new grants take effect on existing files; --force rewraps
after removing readers.
added 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).
+#
+# 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 ;;
+ -*) 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
+ {
+ echo "*: $OWNER, git@cosmocr.at"
+ if [ "$PUBLIC" = 1 ]; then
+ echo "read: all"
+ echo "list: all"
+ 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"
added scripts/update-access.sh
+#!/bin/bash
+# update-access.sh — edit the Access file of an upspin directory (typically a
+# gitweb-hosted repo) and re-share, so the new grants actually take effect on
+# already-uploaded files.
+#
+# Usage:
+# scripts/update-access.sh [flags] <upspin-dir>
+#
+# <upspin-dir> directory whose Access file to update,
+# e.g. rpg137@gmail.com/myrepo.git
+# --file FILE replace the Access file with FILE (non-interactive);
+# default is to open the current one in $EDITOR
+# --fix-only leave the Access file alone, only re-share (use after an
+# Access change made by other means)
+# --force rewrap keys regardless of current state; needed for the
+# change to stick after REMOVING readers
+# --config FILE upspin config to use (default $HOME/upspin/config)
+#
+# Why the re-share step: with ee packing, each file's decryption key is
+# wrapped per reader at write time. Editing Access changes who *should* read,
+# but not the wrapped keys — `upspin share -fix -r` reconciles them (adding
+# the cleartext "all" entry for read:all, and per-user wraps for new grants).
+#
+# Revocation caveat: removing a reader (even with --force) only removes their
+# wrapped key going forward; they may have already saved file keys or content.
+# For real secrecy after a leak, rotate keys / re-encrypt (see `upspin rotate`
+# and `upspin share -help`).
+set -euo pipefail
+
+die() { echo "update-access.sh: $*" >&2; exit 1; }
+
+CONFIG="${HOME}/upspin/config"
+FILE=""
+FIXONLY=0
+FORCE=0
+ARGS=()
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --file) shift; FILE="${1:?--file needs a file}" ;;
+ --fix-only) FIXONLY=1 ;;
+ --force) FORCE=1 ;;
+ --config) shift; CONFIG="${1:?--config needs a file}" ;;
+ -h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ -*) die "unknown flag $1" ;;
+ *) ARGS+=("$1") ;;
+ esac
+ shift
+done
+[ "${#ARGS[@]}" -eq 1 ] || die "usage: update-access.sh [--file FILE | --fix-only] [--force] [--config FILE] <upspin-dir>"
+DIR="${ARGS[0]%/}"
+[ -z "$FILE" ] || [ "$FIXONLY" = 0 ] || die "--file and --fix-only are mutually exclusive"
+
+command -v upspin >/dev/null || die "upspin not found"
+[ -f "$CONFIG" ] || die "upspin config not found: $CONFIG"
+case "${DIR%%/*}" in
+ *@*.*) ;;
+ *) die "path must start with an upspin user (user@domain/...): $DIR" ;;
+esac
+UP() { upspin -config="$CONFIG" "$@"; }
+
+UP ls "$DIR" >/dev/null 2>&1 || die "$DIR does not exist or is not readable"
+ACCESSPATH="$DIR/Access"
+
+if [ "$FIXONLY" = 0 ]; then
+ TMP=$(mktemp -d)
+ trap 'rm -rf "$TMP"' EXIT
+ NEW="$TMP/Access"
+ if [ -n "$FILE" ]; then
+ cp "$FILE" "$NEW"
+ else
+ # Start from the current Access file (empty if none exists yet — note
+ # that without one, access is governed by the nearest ancestor's).
+ UP get "$ACCESSPATH" > "$NEW" 2>/dev/null || : > "$NEW"
+ [ -t 0 ] || die "stdin is not a terminal; use --file for non-interactive updates"
+ OLDSUM=$(sha256sum "$NEW" | cut -d' ' -f1)
+ ${EDITOR:-vi} "$NEW"
+ if [ "$(sha256sum "$NEW" | cut -d' ' -f1)" = "$OLDSUM" ]; then
+ echo "Access unchanged; re-sharing anyway"
+ fi
+ fi
+ [ -s "$NEW" ] || die "refusing to install an empty Access file (rm $ACCESSPATH to fall back to inherited access)"
+ UP cp "$NEW" "$ACCESSPATH"
+ echo "==> installed $ACCESSPATH:"
+ sed 's/^/ /' "$NEW"
+fi
+
+echo "==> re-sharing (rewrapping file keys to match Access)"
+if [ "$FORCE" = 1 ]; then
+ UP share -fix -force -r -q "$DIR"
+else
+ UP share -fix -r -q "$DIR"
+fi
+
+# Show the resulting state; any "keys disagree" section here means trouble.
+echo "==> resulting access:"
+UP share -d "$DIR" | sed 's/^/ /' | head -20