| 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" |