Download
185 lines 7.5 KiBBashModified 2026-10-27executable raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
#!/bin/bash
# Usage: tools/create-repo [--dry-run] [--yes] [-d DESCRIPTION] [--github [--private]]
#                          [--section TITLE] [--config FILE] [--key FILE]
#                          [--git-host HOST] [--remote NAME] SERVER [NAME]
#
# Run in a working copy: makes the repository on SERVER, for a server set
# up as in docs/openbsd.md, adds it here as a remote, and pushes to it.
#
#   - with --github, creates the repository on GitHub too (public unless
#     --private), if it is not there, as SERVER's mirror
#   - creates NAME.git on SERVER with new-repo, wired to the renderer, with
#     the current branch as its default
#   - with --section, lists it in that section of the renderer's config, so
#     it is on the index; the config is written only if repo-render check
#     passes, and the old one kept as .bak. Without, it is private.
#   - adds git@HOST:NAME.git as the remote (origin unless --remote), and
#     pushes the branch and the tags, which the hook renders and mirrors
#
# Everything that needs root runs in one doas on SERVER, after asking
# (--yes doesn't ask); --dry-run prints what it would do instead.
#
#   SERVER          what ssh logs you in to SERVER as, with doas
#   NAME            the repository's name (default: this directory's)
#   --config FILE   the renderer's config on SERVER
#                   (default /etc/repo-render/repo-render.yaml)
#   --key FILE      an ssh key on SERVER for pushing to GitHub, which the
#                   repository's core.sshCommand is set to use
#   --git-host HOST where to push as git (default: SERVER's host name)
#
# Needs git, ssh and mikefarah's yq here, gh (logged in) for --github, and
# repo-render-new-repo on SERVER.
# shellcheck disable=SC2029 # the commands are meant to expand here, then run there
set -euo pipefail

usage() {
	sed -n '2,5s/^# //p' "$0" >&2
	exit 2
}
die() { echo "create-repo: $*" >&2; exit 1; }

config=/etc/repo-render/repo-render.yaml
dry="" yes="" description="" github="" private="" section="" key="" git_host="" remote=origin
server="" name=""
while (($#)); do
	case $1 in
		--dry-run) dry=1 ;;
		--yes) yes=1 ;;
		-d) description=${2?}; shift ;;
		--github) github=1 ;;
		--private) private=1 ;;
		--section) section=${2?}; shift ;;
		--config) config=${2?}; shift ;;
		--key) key=${2?}; shift ;;
		--git-host) git_host=${2?}; shift ;;
		--remote) remote=${2?}; shift ;;
		-*) usage ;;
		*)
			if [[ ! $server ]]; then server=$1
			elif [[ ! $name ]]; then name=$1
			else usage
			fi
			;;
	esac
	shift
done
[[ $server ]] || usage
[[ $git_host ]] || git_host=${server#*@}
[[ $private && ! $github ]] && die "--private is for --github"

top=$(git rev-parse --show-toplevel 2>/dev/null) || die "not in a git working copy"
[[ $name ]] || name=$(basename "$top")
[[ $name =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $name != *.git ]] || die "$name: a name has only letters, digits and . _ - (and no .git)"
branch=$(git branch --show-current)
[[ $branch ]] || die "not on a branch"
[[ $branch =~ ^[A-Za-z0-9_][A-Za-z0-9._/-]*$ ]] || die "branch $branch: letters, digits and . _ - / only"
git remote get-url "$remote" >/dev/null 2>&1 && die "this working copy has a remote $remote already: give --remote NAME"
description=${description//$'\n'/ }
# These go into commands run as root on SERVER.
for p in "$config" "$key"; do
	[[ -z $p || $p =~ ^/[A-Za-z0-9._/-]+$ ]] || die "$p: an absolute path of letters, digits and . _ - / only"
done
need=(git ssh yq)
[[ $github ]] && need+=(gh)
for p in "${need[@]}"; do
	command -v "$p" >/dev/null || die "needs $p"
done

# Runs a command, or with --dry-run prints it, quoted to paste into a shell
run() { if [[ $dry ]]; then printf '%q ' "$@"; echo; else "$@"; fi; }
# Quotes a string for sh, in single quotes
sq() { printf "'%s'" "${1//\'/\'\\\'\'}"; }
# A command line for sh, each word quoted
sqline() { local out="" w; for w in "$@"; do out+="${out:+ }$(sq "$w")"; done; printf '%s' "$out"; }
ask() {
	[[ $yes || $dry ]] && return 0
	read -rp "$1 [y/N] " answer
	[[ $answer == [yY]* ]]
}

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

ssh "$server" cat "$config" >"$tmp/old.yaml"
git_dir=$(yq '.git_dir // ""' "$tmp/old.yaml")
[[ $git_dir =~ ^/[A-Za-z0-9._/-]+$ ]] || die "$config on $server has no usable git_dir (got '$git_dir')"
dir=$git_dir/$name.git
ssh "$server" "test ! -e $(sq "$dir")" || die "$dir is on $server already"

# GitHub's repository is made once SERVER's is, before the push, which
# the hook mirrors to it.
mirror="" make_github=()
if [[ $github ]]; then
	if full=$(gh repo view "$name" --json nameWithOwner --jq .nameWithOwner 2>/dev/null); then
		echo "GitHub has $full already: it becomes the mirror"
	else
		full=$(gh api user --jq .login)/$name
		visibility=--public
		[[ $private ]] && visibility=--private
		make_github=(gh repo create "$name" "$visibility")
		[[ $description ]] && make_github+=(--description "$description")
		echo "GitHub has no $full: it will be made, as the mirror"
	fi
	mirror=git@github.com:$full.git
fi

as_git=()
args=(-c "$config" -b "$branch")
[[ $description ]] && args+=(-d "$description")
[[ $mirror ]] && args+=(-m "$mirror")
as_git+=("$(sqline repo-render-new-repo "${args[@]}" "$dir")")
if [[ $key && $mirror ]]; then
	as_git+=("$(sqline git -C "$dir" config core.sshCommand "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -i $key")")
fi
# With --section, the new config is checked before the repository is
# made, and put in place after: a config that fails the check changes
# nothing, and one that passes is only written once the repository is
# there.
create=""
for c in "${as_git[@]}"; do
	create+="su -s /bin/sh git -c $(sq "$c")"
	[[ $section ]] && create+=" || { rm -f $(sq "$config.new"); exit 1; }"
	create+="; "
done
root=$create

if [[ $section ]]; then
	n=$(T=$section yq '[.sections[] | select(.title == strenv(T))] | length' "$tmp/old.yaml")
	((n == 1)) || die "$config on $server has no section titled '$section'"
	N=$name T=$section yq '(.sections[] | select(.title == strenv(T)) | .repos) += [strenv(N)]' "$tmp/old.yaml" >"$tmp/new.yaml"
	diff -u --label "$config" --label "$config (new)" "$tmp/old.yaml" "$tmp/new.yaml" || true
	home=$(ssh "$server" pwd)
	upload=$home/.repo-render.yaml.new
	[[ $upload =~ ^/[A-Za-z0-9._/-]+$ ]] || die "$server: can't use the home directory $home"
	check=$(sqline repo-render --config "$config.new" check)
	root="install -m 644 $(sq "$upload") $(sq "$config.new"); rm $(sq "$upload"); "
	root+="if ! out=\$(su -s /bin/sh git -c $(sq "$check") 2>&1); then echo \"\$out\" >&2; rm $(sq "$config.new"); echo 'the new config fails repo-render check: nothing changed' >&2; exit 1; fi; "
	root+=$create
	root+="cp -p $(sq "$config") $(sq "$config.bak"); mv $(sq "$config.new") $(sq "$config"); echo $(sq "wrote $config (the old one is $config.bak)"); "
fi

echo "To run as root on $server:"
printf '    %s\n' "$root"
if [[ $dry ]]; then
	[[ $section ]] && echo "(dry run: the new config is not uploaded to $server:$upload)"
	echo "(dry run: not run)"
elif ask "Run it?"; then
	if [[ $section ]]; then
		ssh "$server" "umask 077 && cat > $(sq "$upload")" <"$tmp/new.yaml"
	fi
	ssh -t "$server" "doas sh -ec $(sq "$root")"
else
	echo "nothing changed on $server"
	exit 1
fi

((${#make_github[@]})) && run "${make_github[@]}"
url=git@$git_host:$name.git
run git remote add "$remote" "$url"
if git rev-parse --verify --quiet HEAD >/dev/null; then
	run git push -u "$remote" "$branch"
	run git push "$remote" --tags
else
	echo "no commits yet: push with git push -u $remote $branch"
fi
[[ $section ]] || echo "$name is in no section of the index, so it is private: --section TITLE lists it"