clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Publish signed release files on refs/releases/<tag>

cat committed 1616038
A release is a commit on refs/releases/<tag> whose tree is the release's
files plus SHA256SUMS and SHA256SUMS.sig, the way a site deploy is a
commit whose tree is the built output. forge release publish builds it
with no parent, so publishing under a tag again replaces the files and
the old ones become garbage, signs SHA256SUMS with ssh-keygen -n
forge-release and the commit like a site deploy, and pushes it with the
tag and the CI notes in one atomic push. forge release verify checks
downloads against SHA256SUMS and its signature against the forge's keys.

pre-receive takes releases from admins only, for a tag that exists once
the push lands, with Tag and Source-Commit trailers naming it and its
commit; the tree must be plain files with URL-safe names, under
forge.releasemaxsize (256 MiB by default), each listed in SHA256SUMS
with its sha256, which a registered key signed for forge-release
(allowed_signers now lists that namespace). forge.releaserequireci true
also wants a verified pass for the tag's tree.

post-receive extracts a public repo's release into the store once and
hard-links its files into /<owner>/<repo>/releases/download/<tag>/, with
latest/ for the newest tagged release; the page build links them into
the directory it swaps in. A private repo's releases are served nowhere:
ssh release get and release ls give them to its readers, and to anyone
else they look like a missing repo. Clones and fetches don't see
refs/releases (uploadpack.hideRefs), and big blobs skip deltas
(core.bigFileThreshold 16m).
commit
1616038df12dd64efb5a03f499359406e76d5904
parent
6dbc768e86675d93ba00550d524f685fa6b1bea0
author
cat <admin@dev.site>
kind
feat risk score 1.7 of 3, Jev's estimate
14 files changed, +867 -11
Mbin/forge356+++++++++++++++++++++++++++++-
Mlib/jev-secrets.ts5+-
Mtest/lib.sh23++
Atest/scenarios/release-admin.sh29++
Atest/scenarios/release-checksum.sh54+++++
Atest/scenarios/release-ci.sh32+++
Atest/scenarios/release-delete.sh28++
Atest/scenarios/release-files.sh45++++
Atest/scenarios/release-hidden.sh41+++
Atest/scenarios/release-private.sh62+++++
Atest/scenarios/release-publish.sh69++++++
Atest/scenarios/release-signature.sh40+++
Atest/scenarios/release-tag.sh40+++
Atest/scenarios/release-verify.sh54+++++
M bin/forge +347 -9
@@ -13,6 +13,9 @@
1313 # forge land --server [<id>|<branch>] the same, done by the forge itself (ssh land), no local checks1414 # forge site deploy [--here] [name...] build .forge/sites entries and publish them1515 # forge site push <dir> <name> publish a prebuilt directory16+# forge release publish <tag> <file>... publish files for a tag, with a signed SHA256SUMS17+# forge release verify [--sums f] [--signers f] [--host git@host] <file>... check downloads18+# forge release ls | rm <tag> this repo's releases; delete one1619 # forge import [--to git@host] [--branches] [--private] <git-url> copy a repo into your forge account1720 #1821 # Server commands run as the git user (see README.md):
@@ -41,6 +44,8 @@ done
4144 FORGE_HOME=$(CDPATH= cd -- "$(dirname -- "$self")/.." && pwd)4245 FORGE_BIN=$FORGE_HOME/bin/forge4346 NS=forge-ci47+# What SHA256SUMS.sig of a release is signed for (forge release publish).48+RNS=forge-release4449 ZERO=00000000000000000000000000000000000000004550 CI_SCRIPT=.forge/ci4651
@@ -72,11 +77,12 @@ split_blocks() {
7277 '7378 }7479 75-# verify_block payload sig allowed_signers: print the signer on success.80+# verify_block payload sig allowed_signers [namespace]: print the signer on81+# success. The namespace is forge-ci's unless given.7682 verify_block() {7783 pr=$(ssh-keygen -Y find-principals -s "$2" -f "$3" 2>/dev/null | head -n 1) || return 17884 [ -n "$pr" ] || return 179- ssh-keygen -Y verify -f "$3" -I "$pr" -n "$NS" -s "$2" <"$1" >/dev/null 2>&1 || return 185+ ssh-keygen -Y verify -f "$3" -I "$pr" -n "${4:-$NS}" -s "$2" <"$1" >/dev/null 2>&1 || return 18086 printf '%s' "$pr"8187 }8288
@@ -511,6 +517,136 @@ cmd_site() {
511517 esac512518 }513519 520+# ---------------------------------------------------------------- releases521+# A release is a commit on refs/releases/<tag> whose tree is the release's522+# files plus SHA256SUMS and SHA256SUMS.sig, the way a site deploy is a commit523+# whose tree is the built output. pre-receive checks the sums and the524+# signature (check_release); post-receive and builds hard-link a public525+# repo's release files under /<owner>/<repo>/releases/download/<tag>/.526+527+# File names a release may have: they become URLs and SHA256SUMS lines.528+release_file_ok() {529+ case $1 in '' | .* | *[!A-Za-z0-9._+-]* | SHA256SUMS | SHA256SUMS.sig) return 1 ;; esac530+}531+532+# Tags a release may be for; latest/ holds the newest release's files.533+release_tag_ok() {534+ case $1 in '' | latest | *[!A-Za-z0-9._/+-]*) return 1 ;; esac535+}536+537+# The commit has no parent: publishing under the tag again replaces the538+# files, and the old ones become garbage instead of staying in the repo for539+# good. The tag and CI notes go in the same atomic push.540+release_publish() {541+ [ $# -ge 2 ] || die "usage: forge release publish <tag> <file>..."542+ tag=$1543+ shift544+ release_tag_ok "$tag" || die "release: tags with releases use letters, digits and . _ / + -, and aren't latest: $tag"545+ src=$(git rev-parse -q --verify "refs/tags/$tag^{commit}") || die "release: no tag $tag here (git tag $tag <commit>)"546+ names=' '547+ for f in "$@"; do548+ n=$(basename -- "$f")549+ [ -f "$f" ] || die "release: no file $f"550+ release_file_ok "$n" || die "release: file names use letters, digits and . _ + -, don't start with a dot and aren't SHA256SUMS: $n"551+ case $names in *" $n "*) die "release: two files named $n" ;; esac552+ names="$names$n "553+ done554+ count=$#555+ key=$(signing_key)556+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-release.XXXXXX")557+ for f in "$@"; do558+ n=$(basename -- "$f")559+ mode=100644560+ if [ -x "$f" ]; then mode=100755; fi561+ printf '%s blob %s\t%s\n' "$mode" "$(git hash-object -w -- "$f")" "$n" >>"$tmp/tree"562+ printf '%s %s\n' "$(sha256 "$f")" "$n" >>"$tmp/sums"563+ done564+ LC_ALL=C sort -k 2 "$tmp/sums" >"$tmp/SHA256SUMS"565+ ssh-keygen -Y sign -n "$RNS" -f "$key" "$tmp/SHA256SUMS" 2>"$tmp/sign.err" ||566+ { cat "$tmp/sign.err" >&2; rm -rf "$tmp"; die "release: signing SHA256SUMS failed"; }567+ for f in SHA256SUMS SHA256SUMS.sig; do printf '100644 blob %s\t%s\n' "$(git hash-object -w -- "$tmp/$f")" "$f" >>"$tmp/tree"; done568+ tree=$(git mktree <"$tmp/tree")569+ rm -rf "$tmp"570+ msg=$(printf 'release %s: %s\n\nTag: %s\nSource-Commit: %s\nBuilt-By: %s@%s\n' \571+ "$tag" "$(git log -1 --format=%s "$src")" "$tag" "$src" "$(id -un)" "$(hostname -s 2>/dev/null || hostname)")572+ commit=$(printf '%s\n' "$msg" | git -c gpg.format=ssh -c "user.signingkey=$key" commit-tree -S "$tree" 2>/dev/null) ||573+ commit=$(printf '%s\n' "$msg" | git commit-tree "$tree")574+ r=$(remote)575+ sync_notes "$r"576+ set -- "refs/tags/$tag:refs/tags/$tag" "+$commit:refs/releases/$tag"577+ if git rev-parse -q --verify refs/notes/ci >/dev/null; then set -- "$@" refs/notes/ci:refs/notes/ci; fi578+ git push -q --atomic "$r" "$@" || die "release $tag: $r refused it"579+ say "release $tag: published $(git rev-parse --short "$commit"), $count file(s) and SHA256SUMS, signed with $key"580+}581+582+# Check downloaded files against SHA256SUMS, and SHA256SUMS against its583+# signature by a key of the forge: from --signers, else ssh <host> signers584+# (--host, forge.allowedsigners, forge.host or the remote, in that order).585+release_verify() {586+ sums= signers= host=587+ while [ $# -gt 0 ]; do588+ case $1 in589+ --sums) sums=${2:?--sums needs a file}; shift ;;590+ --signers) signers=${2:?--signers needs a file}; shift ;;591+ --host) host=${2:?--host needs git@host}; shift ;;592+ -*) die "release verify: unknown flag $1" ;;593+ *) break ;;594+ esac595+ shift596+ done597+ [ $# -ge 1 ] || die "usage: forge release verify [--sums SHA256SUMS] [--signers file] [--host git@host] <file>..."598+ [ -n "$sums" ] || sums=$(dirname -- "$1")/SHA256SUMS599+ [ -f "$sums" ] || die "release verify: no $sums; download SHA256SUMS and SHA256SUMS.sig next to the files, or pass --sums"600+ [ -f "$sums.sig" ] || die "release verify: no $sums.sig, so nothing vouches for $sums"601+ tmp=602+ if [ -z "$signers" ] && [ -z "$host" ]; then603+ signers=$(git config --get forge.allowedsigners 2>/dev/null || true)604+ case $signers in "~/"*) signers=$HOME/${signers#"~/"} ;; esac605+ fi606+ if [ -z "$signers" ]; then607+ tmp=$(mktemp)608+ [ -n "$host" ] || host=$(git config --get forge.host 2>/dev/null || true)609+ if [ -n "$host" ]; then ${GIT_SSH_COMMAND:-ssh} "$host" signers </dev/null >"$tmp" 2>/dev/null; else forge_ssh signers </dev/null >"$tmp" 2>/dev/null; fi ||610+ { rm -f "$tmp"; die "release verify: could not get the forge's keys; pass --signers <file> or --host git@host"; }611+ signers=$tmp612+ fi613+ if ! pr=$(verify_block "$sums" "$sums.sig" "$signers" "$RNS"); then614+ [ -z "$tmp" ] || rm -f "$tmp"615+ die "release verify: $sums.sig is not a good $RNS signature by a key of the forge"616+ fi617+ [ -z "$tmp" ] || rm -f "$tmp"618+ bad=0619+ for f in "$@"; do620+ n=$(basename -- "$f")621+ want=$(LC_ALL=C awk -v n="$n" 'NF == 2 { m = $2; sub(/^\*/, "", m); if (m == n) { print $1; exit } }' "$sums")622+ if [ -z "$want" ]; then say "$n: not in $sums"; bad=1623+ elif [ ! -f "$f" ]; then say "$n: no such file"; bad=1624+ elif [ "$(sha256 "$f")" != "$want" ]; then say "$n: DOES NOT MATCH its sha256 in $sums"; bad=1625+ else echo "ok $n: matches $sums, signed by $pr"; fi626+ done627+ [ "$bad" = 0 ] || die "release verify: not every file checks out"628+}629+630+cmd_release() {631+ sub=${1:-}632+ [ $# -gt 0 ] && shift633+ case $sub in634+ publish) release_publish "$@" ;;635+ verify) release_verify "$@" ;;636+ ls)637+ [ $# -eq 0 ] || die "usage: forge release ls"638+ repo=$(remote_repo) || exit 1639+ forge_ssh release ls "$repo" </dev/null640+ ;;641+ rm)642+ [ $# -eq 1 ] || die "usage: forge release rm <tag>"643+ git push -q "$(remote)" ":refs/releases/$1"644+ say "release $1: deleted"645+ ;;646+ *) die "usage: forge release publish <tag> <file>... | verify [--sums f] [--signers f] [--host git@host] <file>... | ls | rm <tag>" ;;647+ esac648+}649+514650 # Copy a repo from anywhere git can clone into your forge account.515651 cmd_import() {516652 to=$(git config --get forge.host || true) name= desc= branches=0 private=
@@ -803,7 +939,7 @@ cmd_server_init() {
803939 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/804940 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/805941 EOF806- for c in new ls whoami signers site-url reviews comment approve approvals land private public readers help; do942+ for c in new ls whoami signers site-url reviews comment approve approvals land private public readers release help; do807943 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"808944 chmod +x "$FORGE_ROOT/git-shell-commands/$c"809945 done
@@ -923,7 +1059,8 @@ EOF
9231059 9241060 # keys holds "name ssh-ed25519 AAAA... comment" lines; it is the only source9251061 # of identity. authorized_keys forces every key through `forge shell <name>`,926-# and allowed_signers lets the same keys sign CI attestations and site deploys.1062+# and allowed_signers lets the same keys sign CI attestations, site deploys1063+# and release checksums.9271064 cmd_keys_sync() {9281065 load_config9291066 mkdir -p "$(dirname "$FORGE_AUTHORIZED_KEYS")"
@@ -934,7 +1071,7 @@ cmd_keys_sync() {
9341071 while read -r name key; do9351072 case $name in '' | '#'*) continue ;; esac9361073 printf 'restrict,command="%s shell %s" %s\n' "$FORGE_BIN" "$name" "$key" >>"$ak"937- printf '%s namespaces="%s,git" %s\n' "$name" "$NS" "$key" >>"$as"1074+ printf '%s namespaces="%s,%s,git" %s\n' "$name" "$NS" "$RNS" "$key" >>"$as"9381075 done <"$FORGE_STATE/keys"9391076 chmod 600 "$ak"9401077 mv "$ak" "$FORGE_AUTHORIZED_KEYS"
@@ -975,12 +1112,17 @@ install_hooks() {
9751112 # revision's pinned tip) is hidden from clones, fetches and pushes; the page9761113 # build reads it directly. Changed-path Bloom filters use version 2, which9771114 # hashes non-ASCII paths the same on every platform (older git ignores it).1115+# Releases (refs/releases/*) hold binaries, so clones and fetches don't see1116+# them either, but pushes do, so forge release publish can replace and1117+# delete them; blobs past core.bigFileThreshold are stored without deltas.9781118 repo_setup() {9791119 install_hooks "$1"9801120 git --git-dir="$1" config receive.advertisePushOptions true9811121 git --git-dir="$1" config --replace-all transfer.hideRefs refs/forge '^refs/forge$'9821122 # transfer.hideRefs covers both of these, which repos used to get.9831123 for k in uploadpack.hideRefs receive.hideRefs; do git --git-dir="$1" config --unset-all "$k" '^refs/forge$' || true; done1124+ git --git-dir="$1" config --replace-all uploadpack.hideRefs refs/releases '^refs/releases$'1125+ git --git-dir="$1" config core.bigFileThreshold 16m9841126 git --git-dir="$1" config commitGraph.changedPathsVersion 29851127 }9861128
@@ -1510,6 +1652,34 @@ sc_readers() {
15101652 esac15111653 }15121654 1655+# release ls <owner>/<repo> | release get <owner>/<repo> <tag> <file>: for1656+# anyone who may read the repo, so a private repo's releases, which nothing1657+# serves, are there for its readers; to anyone else it is a missing repo.1658+# ls prints tag, file, size and sha256 (from SHA256SUMS), newest first.1659+sc_release() {1660+ case ${1:-}:$# in1661+ ls:2)1662+ open_repo "$2"1663+ for t in $(git --git-dir="$RDIR" for-each-ref --sort=-creatordate --format='%(refname:lstrip=2)' refs/releases/); do1664+ {1665+ git --git-dir="$RDIR" cat-file blob "refs/releases/$t:SHA256SUMS" 2>/dev/null | sed 's/^/s /'1666+ git --git-dir="$RDIR" ls-tree -l "refs/releases/$t" | sed 's/^/t /'1667+ } | LC_ALL=C awk -v t="$t" '1668+ $1 == "s" { n = $3; sub(/^\*/, "", n); h[n] = $2; next }1669+ { printf "%s\t%s\t%s\t%s\n", t, $6, $5, ($6 in h) ? h[$6] : "-" }'1670+ done1671+ ;;1672+ get:4)1673+ open_repo "$2"1674+ release_tag_ok "$3" && git --git-dir="$RDIR" rev-parse -q --verify "refs/releases/$3^{commit}" >/dev/null || die "no release $3 in $2"1675+ case $4 in SHA256SUMS | SHA256SUMS.sig) ;; *) release_file_ok "$4" || die "release $3 of $2 has no file $4" ;; esac1676+ [ "$(git --git-dir="$RDIR" cat-file -t "refs/releases/$3:$4" 2>/dev/null)" = blob ] || die "release $3 of $2 has no file $4"1677+ exec git --git-dir="$RDIR" cat-file blob "refs/releases/$3:$4"1678+ ;;1679+ *) die "usage: release ls <owner>/<repo> | release get <owner>/<repo> <tag> <file>" ;;1680+ esac1681+}1682+15131683 # Commands reachable over ssh through git-shell-commands/.15141684 cmd_shell_cmd() {15151685 load_config
@@ -1570,7 +1740,8 @@ cmd_shell_cmd() {
15701740 private) sc_private "$@" ;;15711741 public) sc_public "$@" ;;15721742 readers) sc_readers "$@" ;;1573- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;1743+ release) sc_release "$@" ;;1744+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;15741745 esac15751746 }15761747
@@ -1601,6 +1772,69 @@ scan_secrets() {
16011772 say "forge: secret check did not run; push not checked"16021773 }16031774 1775+# check_release <commit> <tag>: refuse a release that isn't what forge1776+# release publish makes for that tag: a commit whose Tag and Source-Commit1777+# trailers name the tag and its commit (as this push leaves it), whose tree1778+# is regular files only, none over forge.releasemaxsize, each listed in1779+# SHA256SUMS with its sha256, and SHA256SUMS.sig a forge-release signature1780+# of SHA256SUMS by a key of this forge. With forge.releaserequireci true,1781+# the tag's tree needs a verified passing CI attestation too. Releases skip1782+# the secret check, which reads branches and tags only. Uses $updates,1783+# $notes_new and the scratch directory $rt.1784+check_release() {1785+ rc=$1 rn="release $2"1786+ release_tag_ok "$2" || deny "$rn: tags with releases use letters, digits and . _ / + -, and aren't latest"1787+ [ "$(git cat-file -t "$rc")" = commit ] || deny "$rn: refs/releases/$2 must be a commit"1788+ rsrc=$(printf '%s\n' "$updates" | awk -v t="refs/tags/$2" '$3 == t { print $2 }')1789+ [ "$rsrc" != "$ZERO" ] || deny "$rn: this push deletes tag $2"1790+ [ -n "$rsrc" ] || rsrc=refs/tags/$21791+ rsrc=$(git rev-parse -q --verify "$rsrc^{commit}") || deny "$rn: no tag $2 here; push it with the release (forge release publish does)"1792+ rtr=$(git log -1 --format='%(trailers:key=Tag,valueonly)' "$rc" | sed '/^$/d')1793+ [ "$rtr" = "$2" ] || deny "$rn: its Tag trailer is ${rtr:-missing}, not $2"1794+ rtr=$(git log -1 --format='%(trailers:key=Source-Commit,valueonly)' "$rc" | sed '/^$/d')1795+ [ "$rtr" = "$rsrc" ] || deny "$rn: its Source-Commit is ${rtr:-missing}, but tag $2 is $rsrc"1796+ rmax=$(git config --int forge.releasemaxsize || echo 268435456)1797+ git ls-tree -l "$rc" >"$rt/tree"1798+ : >"$rt/files"1799+ while read -r rmode rtype roid rsize rname; do1800+ case $rmode in 100644 | 100755) ;; *) deny "$rn: $rname is not a file; a release holds files only, no directories or links" ;; esac1801+ case $rname in1802+ SHA256SUMS | SHA256SUMS.sig) ;;1803+ *)1804+ release_file_ok "$rname" || deny "$rn: file names use letters, digits and . _ + -, and don't start with a dot: $rname"1805+ printf '%s %s\n' "$rname" "$roid" >>"$rt/files"1806+ ;;1807+ esac1808+ [ "$rsize" -le "$rmax" ] || deny "$rn: $rname is $rsize bytes, over forge.releasemaxsize ($rmax)"1809+ done <"$rt/tree"1810+ [ -s "$rt/files" ] || deny "$rn: no files"1811+ git cat-file blob "$rc:SHA256SUMS" >"$rt/sums" 2>/dev/null || deny "$rn: no SHA256SUMS"1812+ git cat-file blob "$rc:SHA256SUMS.sig" >"$rt/sums.sig" 2>/dev/null ||1813+ deny "$rn: no SHA256SUMS.sig; sign SHA256SUMS with ssh-keygen -Y sign -n $RNS (forge release publish does)"1814+ # "<sha256> <name>" lines, as sha256sum writes them ("*<name>" in its binary mode).1815+ LC_ALL=C awk 'NF == 0 { next } NF != 2 || length($1) != 64 || $1 !~ /^[0-9a-f]+$/ { bad = 1; exit }1816+ { n = $2; sub(/^\*/, "", n); print n, $1 } END { exit bad }' "$rt/sums" >"$rt/listed" ||1817+ deny "$rn: SHA256SUMS has a line that isn't <sha256> <file>"1818+ rx=$(cut -d' ' -f1 "$rt/listed" | LC_ALL=C sort | uniq -d | head -n 1)1819+ [ -z "$rx" ] || deny "$rn: SHA256SUMS lists $rx twice"1820+ cut -d' ' -f1 "$rt/listed" | LC_ALL=C sort >"$rt/listed.names"1821+ rx=$(cut -d' ' -f1 "$rt/files" | LC_ALL=C sort | LC_ALL=C comm -13 - "$rt/listed.names" | head -n 1)1822+ [ -z "$rx" ] || deny "$rn: SHA256SUMS lists $rx, which the release doesn't have"1823+ while read -r rname roid; do1824+ rx=$(awk -v n="$rname" '$1 == n { print $2 }' "$rt/listed")1825+ [ -n "$rx" ] || deny "$rn: SHA256SUMS doesn't list $rname"1826+ [ "$(git cat-file blob "$roid" | sha256 -)" = "$rx" ] || deny "$rn: $rname doesn't match its sha256 in SHA256SUMS"1827+ done <"$rt/files"1828+ verify_block "$rt/sums" "$rt/sums.sig" "$FORGE_STATE/allowed_signers" "$RNS" >/dev/null ||1829+ deny "$rn: SHA256SUMS.sig is not a good $RNS signature by a key of this forge"1830+ [ "$(git config --bool forge.releaserequireci || echo false)" = true ] || return 01831+ notes=${notes_new:-refs/notes/ci}1832+ [ "$notes" != "$ZERO" ] || notes=refs/notes/ci1833+ signers=$FORGE_STATE/allowed_signers quiet=11834+ verify_tree "$(git rev-parse "$rsrc^{tree}")" "$rsrc" ||1835+ deny "$rn: $(git rev-parse --short "$rsrc") has no verified passing CI attestation (run: forge attest $2; forge release publish pushes the notes)"1836+}1837+16041838 hook_pre_receive() {16051839 repo_ctx16061840 # forge shell turns away anyone who can't read a private repo; this is in
@@ -1608,7 +1842,7 @@ hook_pre_receive() {
16081842 [ -z "$USER_" ] || can_read "$GIT_DIR" "$USER_" || deny "no repo $OWNER/$NAME"16091843 # The default branch first, so a refused push explains that one first.16101844 updates=$(awk -v d="refs/heads/$DEFAULT" '$3 == d { print; next } { r = r $0 "\n" } END { printf "%s", r }')1611- notes_new=1845+ notes_new= releases=16121846 while read -r old new ref; do16131847 [ -n "$ref" ] || continue16141848 mine=0
@@ -1627,6 +1861,11 @@ hook_pre_receive() {
16271861 ;;16281862 refs/heads/* | refs/site-previews/*) [ "$ADMIN" = 1 ] || [ "$mine" = 1 ] || deny "$ref: push under refs/heads/$USER_/" ;;16291863 refs/tags/* | refs/sites/*) [ "$ADMIN" = 1 ] || deny "$ref: only $OWNER can update this" ;;1864+ refs/releases/*)1865+ [ "$ADMIN" = 1 ] || deny "$ref: only $OWNER can update this"1866+ [ "$new" = "$ZERO" ] || releases="$releases$new ${ref#refs/releases/}1867+"1868+ ;;16301869 refs/notes/ci)16311870 # Anyone may add attestations (they are signed); only admins may rewrite.16321871 if [ "$ADMIN" = 0 ]; then
@@ -1644,6 +1883,16 @@ hook_pre_receive() {
16441883 16451884 scan_secrets16461885 1886+ if [ -n "$releases" ]; then1887+ rt=$(mktemp -d "${TMPDIR:-/tmp}/forge-release.XXXXXX")1888+ trap 'rm -rf "$rt"' EXIT1889+ while read -r rnew rtag; do1890+ [ -z "$rnew" ] || check_release "$rnew" "$rtag"1891+ done <<-EOF1892+ $releases1893+ EOF1894+ fi1895+16471896 # Approvals are keyed by tree, like CI, and must come from someone other16481897 # than the review's author (see approvers).16491898 if [ "$(git config --bool forge.requireapproval || echo false)" = true ]; then
@@ -1767,6 +2016,84 @@ push_site() {
17672016 fi17682017 }17692018 2019+# store_tree <commit>: extract its tree once into the content store, as2020+# deploy_site does, and print where. git archive stamps every file with the2021+# commit's time, so a tree extracted again (after a restore) gets the same2022+# mtimes. A reused tree counts as new, so a concurrent forge gc leaves it.2023+store_tree() {2024+ st_dir=$FORGE_STATE/store/$(git rev-parse "$1^{tree}")2025+ if [ ! -d "$st_dir" ]; then2026+ rm -rf "$st_dir.tmp.$$"2027+ mkdir -p "$st_dir.tmp.$$"2028+ git archive "$1" | tar -x -C "$st_dir.tmp.$$"2029+ if [ -d "$st_dir" ]; then rm -rf "$st_dir.tmp.$$"; else mv "$st_dir.tmp.$$" "$st_dir"; fi2030+ fi2031+ touch -c "$st_dir"2032+ printf '%s' "$st_dir"2033+}2034+2035+# release_link <root> <tag> <commit|ZERO>: make2036+# <root>/<owner>/<repo>/releases/download/<tag>/ hard links to the files of2037+# a release, extracted once into the store, or remove it. Hard links, not a2038+# symlink like a site's: rclone (forge mirror) skips symlinks, and the files2039+# keep the store's mtime. Runs in a repo (GIT_DIR) with OWNER and NAME set;2040+# <root> is FORGE_WWW for a public repo.2041+release_link() {2042+ rl=$1/$OWNER/$NAME/releases/download/$(enc_ref "$2")2043+ if [ "$3" = "$ZERO" ]; then2044+ rm -rf "$rl"2045+ return 02046+ fi2047+ rs=$(store_tree "$3")2048+ rm -rf "$rl.tmp.$$"2049+ link_files "$rl.tmp.$$" "$rs"/*2050+ swap "$rl.tmp.$$" "$rl"2051+}2052+2053+# release_latest <root>: releases/download/latest/ holds the files of the2054+# newest tag that has a release, newest as the releases page orders tags, so2055+# a link to latest/<file> keeps working from one release to the next.2056+release_latest() {2057+ rl_tag=$({2058+ git for-each-ref --format='r %(refname:lstrip=2)' refs/releases/2059+ git for-each-ref --sort=-creatordate --format='t %(refname:lstrip=2)' refs/tags/2060+ } | awk '$1 == "r" { r[$2] = 1; next } ($2 in r) { print $2; exit }')2061+ if [ -n "$rl_tag" ]; then2062+ release_link "$1" latest "refs/releases/$rl_tag"2063+ else2064+ release_link "$1" latest "$ZERO"2065+ rmdir "$1/$OWNER/$NAME/releases/download" 2>/dev/null || true2066+ fi2067+}2068+2069+# releases_into <root>: every release of the repo, and latest, under <root>,2070+# with the directories of deleted ones gone. Builds link them into the pages2071+# they swap in, restores and gc into the live pages.2072+releases_into() {2073+ for ref in $(git for-each-ref --format='%(refname)' refs/releases/); do2074+ release_link "$1" "${ref#refs/releases/}" "$ref"2075+ done2076+ for d in "$1/$OWNER/$NAME/releases/download"/*/; do2077+ [ -d "$d" ] || continue2078+ t=$(basename "$d" | tr '~' /)2079+ [ "$t" = latest ] || git rev-parse -q --verify "refs/releases/$t" >/dev/null || rm -rf "$d"2080+ done2081+ release_latest "$1"2082+}2083+2084+# push_release <tag> <commit|ZERO>: after a push, serve the release's files,2085+# or stop. A private repo's are served nowhere; its readers get them over2086+# ssh (sc_release).2087+push_release() {2088+ if [ ! -f "$GIT_DIR/git-daemon-export-ok" ]; then2089+ [ "$2" = "$ZERO" ] || say "release $1: not served, since $OWNER/$NAME is private; its readers get the files with ssh ${FORGE_SSH:-git@host} release get $OWNER/$NAME $1 <file>"2090+ return 02091+ fi2092+ release_link "$FORGE_WWW" "$1" "$2"2093+ u=${FORGE_BASE_URL:-}2094+ [ "$2" = "$ZERO" ] || say "release $1: ${u%/}/$OWNER/$NAME/releases/download/$(enc_ref "$1")/"2095+}2096+17702097 hook_post_receive() {17712098 repo_ctx17722099 # FORGE_NOW pins the clock for review logs; the demo and tests set it.
@@ -1802,9 +2129,10 @@ noreview
18022129 DEFAULT=$first18032130 fi18042131 fi1805- PUSHED=2132+ PUSHED= rel=018062133 while read -r old new ref; do1807- case $ref in refs/heads/* | refs/tags/* | refs/sites/*) PUSHED="$PUSHED $ref" ;; esac2134+ case $ref in refs/heads/* | refs/tags/* | refs/sites/* | refs/releases/*) PUSHED="$PUSHED $ref" ;; esac2135+ case $ref in refs/tags/* | refs/releases/*) rel=1 ;; esac18082136 [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac18092137 case $ref in18102138 "refs/heads/$DEFAULT") ;;
@@ -1814,10 +2142,13 @@ noreview
18142142 rest=${ref#refs/site-previews/}18152143 push_site "previews/$OWNER/$NAME/${rest%%/*}/$(enc_ref "${rest#*/}")" "$new"18162144 ;;2145+ refs/releases/*) push_release "${ref#refs/releases/}" "$new" ;;18172146 esac18182147 done <<-EOF18192148 $updates18202149 EOF2150+ # A new tag can be the newest with a release, or a deleted one can stop being it.2151+ if [ "$rel" = 1 ] && [ -f "$GIT_DIR/git-daemon-export-ok" ]; then release_latest "$FORGE_WWW"; fi18212152 git update-server-info18222153 refs=$(printf '%s' "$PUSHED" | tr -d '"\\')18232154 printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"%s}\n' "$(date +%s)" "$OWNER/$NAME" "${USER_:-admin}" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"
@@ -1903,6 +2234,12 @@ cmd_build() {
19032234 (cd "$FORGE_HOME" && FORGE_ONLY=$only FORGE_WWW=$out FORGE_LIVE_WWW=$live FORGE_BUILD_STARTED=$((t0 * 1000)) FORGE_BUILD_REASON=${FORGE_BUILD_REASON:-manual} \19042235 "$FORGE_HOME/node_modules/.bin/eleventy" --config=eleventy.config.ts --quiet --output="$out")19052236 mkdir -p "$FORGE_WWW"2237+ # The swap replaces the repo's directory, release downloads too, so the2238+ # new one gets them from the refs: hard links, made in no time.2239+ if [ "$only" != - ] && [ -d "$out/$only" ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ]; then2240+ (trap - EXIT && export GIT_DIR="$FORGE_ROOT/$only.git" && OWNER=${only%%/*} NAME=${only#*/} && releases_into "$out") ||2241+ say "forge: linking the release files of $only failed"2242+ fi19062243 # Swap generated pages in. sites/, previews/ and hosts/ belong to the hooks. A19072244 # single-repo build refreshes owner index pages but only replaces that repo.19082245 for e in "$out"/*; do
@@ -2558,6 +2895,7 @@ comment) cmd_comment "$@" ;;
25582895 approve) cmd_approve "$@" ;;25592896 land) cmd_land "$@" ;;25602897 site) cmd_site "$@" ;;2898+release) cmd_release "$@" ;;25612899 server)25622900 case ${1:-} in25632901 init) cmd_server_init ;;
M lib/jev-secrets.ts +3 -2
M test/lib.sh +23 -0
@@ -15,6 +15,7 @@
1515 # build [dami/greet] write pages (11ty) into $FORGE_WWW1616 # repo --private dami/secret a private repo (no git-daemon-export-ok)1717 # site_commit w "text" a commit of a one-page site, to push to refs/sites/...18+# release_commit w v1 dir [key] a release commit of dir's files, to push to refs/releases/v11819 # aside dami/secret ... back dami/secret move a repo out of the forge and back1920 # sync_builds hooks and ssh commands build pages before returning2021 #
@@ -174,6 +175,28 @@ site_commit() {
174175 t=$(printf '100644 blob %s\tindex.html\n' "$b" | git mktree) && git commit-tree -m "site: $2" "$t")175176 }176177 178+t_sha256() { { sha256sum "$1" 2>/dev/null || shasum -a 256 "$1"; } | cut -d' ' -f1; }179+180+# release_commit dir tag files-dir [key|-]: a commit for refs/releases/<tag>181+# made by hand, the way forge release publish makes one, from every file in182+# files-dir. SHA256SUMS and SHA256SUMS.sig (signed with key, dami's by183+# default) are written unless files-dir has them already; with - there is184+# no signature. R_TAG and R_SRC set other Tag and Source-Commit trailers.185+release_commit() {186+ (187+ cd "$1" || exit 1188+ src=$(git rev-parse "refs/tags/$2^{commit}")189+ k=${4:-$T/keys/dami}190+ if [ ! -f "$3/SHA256SUMS" ]; then191+ for f in "$3"/*; do printf '%s %s\n' "$(t_sha256 "$f")" "${f##*/}"; done >"$T/sums.tmp"192+ mv "$T/sums.tmp" "$3/SHA256SUMS"193+ fi194+ if [ "$k" != - ] && [ ! -f "$3/SHA256SUMS.sig" ]; then ssh-keygen -q -Y sign -n forge-release -f "$k" "$3/SHA256SUMS" 2>/dev/null; fi195+ t=$(for f in "$3"/*; do printf '100644 blob %s\t%s\n' "$(git hash-object -w "$f")" "${f##*/}"; done | git mktree)196+ printf 'release %s\n\nTag: %s\nSource-Commit: %s\nBuilt-By: test\n' "$2" "${R_TAG:-$2}" "${R_SRC:-$src}" | git commit-tree "$t"197+ )198+}199+177200 # aside slug, back slug: take a repo out of the forge and put it back, so a178201 # scenario can see what a missing repo of the same name looks like.179202 aside() { mv "$FORGE_ROOT/$1.git" "$T/aside.git"; }
A test/scenarios/release-admin.sh +29 -0
A test/scenarios/release-checksum.sh +54 -0
@@ -0,0 +1,54 @@
1+# SHA256SUMS must list every file of a release, each with its sha256 and2+# nothing else, in sha256sum's format; otherwise the push is refused and3+# nothing is served.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet7+clone dami dami/greet "$T/w/d"8+at "$T/w/d" commit "first"9+at "$T/w/d" git tag v110+ok push_as dami "$T/w/d" origin main v111+# mk <SHA256SUMS text>: c is a release of greet and notes with that12+# SHA256SUMS, signed by dami. try <regex> <text>: it is refused for the reason.13+mk() {14+ rm -rf "$T/f"15+ mkdir "$T/f"16+ printf 'greet\n' >"$T/f/greet"17+ printf 'notes\n' >"$T/f/notes"18+ printf '%s' "$1" >"$T/f/SHA256SUMS"19+ c=$(release_commit "$T/w/d" v1 "$T/f")20+}21+try() {22+ mk "$2"23+ fails_with "$1" push_as dami "$T/w/d" origin "$c:refs/releases/v1"24+}25+g=$(printf 'greet\n' | { sha256sum 2>/dev/null || shasum -a 256; } | cut -d' ' -f1)26+n=$(printf 'notes\n' | { sha256sum 2>/dev/null || shasum -a 256; } | cut -d' ' -f1)27+try "release v1: notes doesn't match its sha256 in SHA256SUMS" "$g greet28+$g notes29+"30+try "release v1: SHA256SUMS doesn't list notes" "$g greet31+"32+try "SHA256SUMS lists extra, which the release doesn't have" "$g greet33+$n notes34+$n extra35+"36+try "SHA256SUMS lists greet twice" "$g greet37+$g greet38+$n notes39+"40+try "SHA256SUMS has a line that isn't" "$g greet notes41+"42+try "SHA256SUMS has a line that isn't" "abc greet43+$n notes44+"45+missing "$FORGE_WWW/dami/greet/releases"46+fails git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse -q --verify refs/releases/v147+48+# Binary mode's "*name" and blank lines are fine.49+mk "$g *greet50+51+$n notes52+"53+ok push_as dami "$T/w/d" origin "$c:refs/releases/v1"54+exists "$FORGE_WWW/dami/greet/releases/download/v1/notes"
A test/scenarios/release-ci.sh +32 -0
@@ -0,0 +1,32 @@
1+# forge.releaserequireci true: a release lands only if its tag's tree has a2+# verified passing CI attestation, in the repo or in the same push (forge3+# release publish pushes refs/notes/ci with it). A failing one won't do.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet7+g=$FORGE_ROOT/dami/greet.git8+git --git-dir="$g" config forge.releaserequireci true9+clone dami dami/greet "$T/w/d"10+mkdir -p "$T/w/d/.forge"11+printf '#!/bin/sh\ntest -f ok\n' >"$T/w/d/.forge/ci"12+chmod +x "$T/w/d/.forge/ci"13+at "$T/w/d" commit "ci script, no ok file yet"14+at "$T/w/d" git tag v115+ok push_as dami "$T/w/d" origin main16+mkdir "$T/f"17+printf 'greet\n' >"$T/f/greet"18+export FORGE_USER=dami19+fails_with 'release v1: [0-9a-f]+ has no verified passing CI attestation \(run: forge attest v1' at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"20+fails at "$T/w/d" "$FORGE" attest v121+fails_with 'has no verified passing CI attestation' at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"22+missing "$FORGE_WWW/dami/greet/releases"23+# The refused push took nothing with it: not the tag, not the notes.24+fails git --git-dir="$g" rev-parse -q --verify refs/tags/v125+fails git --git-dir="$g" rev-parse -q --verify refs/notes/ci26+27+at "$T/w/d" commit "add ok" ok28+at "$T/w/d" git tag v229+ok at "$T/w/d" "$FORGE" attest v230+ok at "$T/w/d" "$FORGE" release publish v2 "$T/f/greet"31+exists "$FORGE_WWW/dami/greet/releases/download/v2/greet"32+git --git-dir="$g" rev-parse -q --verify refs/notes/ci >/dev/null || fail "the notes came with the release"
A test/scenarios/release-delete.sh +28 -0
A test/scenarios/release-files.sh +45 -0
@@ -0,0 +1,45 @@
1+# A release is regular files at the top of its tree, with names that make2+# plain URLs, none over forge.releasemaxsize (256 MiB unless set).3+. "$(dirname "$0")/../lib.sh"4+user dami5+repo dami/greet6+clone dami dami/greet "$T/w/d"7+at "$T/w/d" commit "first"8+at "$T/w/d" git tag v19+ok push_as dami "$T/w/d" origin main v110+g=$FORGE_ROOT/dami/greet.git11+mkdir "$T/f"12+head -c 3000 /dev/zero >"$T/f/big.bin"13+printf 'small\n' >"$T/f/small"14+15+git --git-dir="$g" config forge.releasemaxsize 2k16+FORGE_USER=dami fails_with 'release v1: big.bin is 3000 bytes, over forge.releasemaxsize \(2048\)' at "$T/w/d" "$FORGE" release publish v1 "$T/f/big.bin" "$T/f/small"17+git --git-dir="$g" config forge.releasemaxsize 4k18+FORGE_USER=dami ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/big.bin" "$T/f/small"19+exists "$FORGE_WWW/dami/greet/releases/download/v1/big.bin"20+git --git-dir="$g" config --unset forge.releasemaxsize21+22+# tree <entries>: a release commit whose tree is SHA256SUMS of the blobs,23+# its signature, and these mktree lines; refused for the reason.24+rm -rf "$T/f"25+mkdir "$T/f"26+printf 'x\n' >"$T/f/x"27+c=$(release_commit "$T/w/d" v1 "$T/f")28+base=$(git -C "$T/w/d" ls-tree "$c")29+x=$(git -C "$T/w/d" hash-object -w "$T/f/x")30+sub=$(printf '100644 blob %s\tx\n' "$x" | git -C "$T/w/d" mktree)31+link=$(printf 'x' | git -C "$T/w/d" hash-object -w --stdin)32+refused() {33+ t=$(printf '%s\n%s\n' "$base" "$1" | git -C "$T/w/d" mktree)34+ c2=$(printf 'release\n\nTag: v1\nSource-Commit: %s\n' "$(git -C "$T/w/d" rev-parse v1)" | git -C "$T/w/d" commit-tree "$t")35+ fails_with "$2" push_as dami "$T/w/d" origin "+$c2:refs/releases/v1"36+}37+refused "$(printf '040000 tree %s\tdir' "$sub")" 'release v1: dir is not a file; a release holds files only'38+refused "$(printf '120000 blob %s\tlink' "$link")" 'release v1: link is not a file'39+refused "$(printf '100644 blob %s\t.env' "$x")" "file names use letters, digits and \. _ \+ -, and don't start with a dot: \.env"40+refused "$(printf '100644 blob %s\tindex page.html' "$x")" 'file names use'41+# Only SHA256SUMS and its signature: nothing to download.42+t=$(git -C "$T/w/d" ls-tree "$c" | grep SHA256SUMS | git -C "$T/w/d" mktree)43+c2=$(printf 'release\n\nTag: v1\nSource-Commit: %s\n' "$(git -C "$T/w/d" rev-parse v1)" | git -C "$T/w/d" commit-tree "$t")44+fails_with 'release v1: no files' push_as dami "$T/w/d" origin "+$c2:refs/releases/v1"45+exists "$FORGE_WWW/dami/greet/releases/download/v1/big.bin"
A test/scenarios/release-hidden.sh +41 -0
@@ -0,0 +1,41 @@
1+# Releases hold binaries, so clones and fetches never see refs/releases2+# (uploadpack.hideRefs), while pushes do, so they can be published again3+# and deleted. Big blobs are stored without deltas (core.bigFileThreshold).4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+repo dami/greet8+g=$FORGE_ROOT/dami/greet.git9+eq "$(git --git-dir="$g" config --get-all uploadpack.hideRefs)" refs/releases10+eq "$(git --git-dir="$g" config core.bigFileThreshold)" 16m11+forge server init >/dev/null 2>&112+eq "$(git --git-dir="$g" config --get-all uploadpack.hideRefs)" refs/releases13+eq "$(git --git-dir="$g" config --get-all transfer.hideRefs)" refs/forge14+15+clone dami dami/greet "$T/w/d"16+at "$T/w/d" commit "first"17+at "$T/w/d" git tag v118+ok push_as dami "$T/w/d" origin main19+mkdir "$T/f"20+printf 'greet\000\n' >"$T/f/greet"21+export FORGE_USER=dami22+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"23+git --git-dir="$g" rev-parse -q --verify refs/releases/v1 >/dev/null || fail "no release ref"24+25+ok fetch_ssh ana dami/greet26+has "$T_LOG" 'refs/tags/v1'27+lacks "$T_LOG" 'refs/releases'28+ok git ls-remote "$g"29+lacks "$T_LOG" 'refs/releases'30+ok clone_ssh ana dami/greet "$T/w/a"31+eq "$(git -C "$T/w/a" for-each-ref refs/releases/ | wc -l | tr -d ' ')" 032+fails at "$T/w/a" git fetch -q origin refs/releases/v133+# The release's blob never came along.34+fails git -C "$T/w/a" cat-file -e "$(git --git-dir="$g" rev-parse refs/releases/v1:greet)"35+36+# Pushes see it: publish again (a forced update) and delete.37+printf 'greet 2\n' >"$T/f/greet"38+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"39+has "$FORGE_WWW/dami/greet/releases/download/v1/greet" 'greet 2'40+ok at "$T/w/d" "$FORGE" release rm v141+fails git --git-dir="$g" rev-parse -q --verify refs/releases/v1
A test/scenarios/release-private.sh +62 -0
@@ -0,0 +1,62 @@
1+# A private repo's releases are served nowhere, but its readers get them2+# over ssh (release ls, release get); to anyone else those commands say what3+# they say about a repo that isn't there. A repo made private loses its4+# public downloads.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user cat9+repo --private dami/secret10+ok ssh_as dami readers dami/secret add ana11+clone dami dami/secret "$T/w/d"12+at "$T/w/d" commit "first"13+at "$T/w/d" git tag v114+ok push_as dami "$T/w/d" origin main15+mkdir "$T/f"16+printf 'secret\000binary\n' >"$T/f/tool"17+export FORGE_USER=dami18+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/tool"19+has "$T_LOG" 'release v1: not served, since dami/secret is private; its readers get the files with ssh git@forge.test release get dami/secret v1 <file>'20+unset FORGE_USER21+missing "$FORGE_WWW/dami/secret"22+eq "$(ls "$FORGE_ROOT/.forge/store" | wc -l | tr -d ' ')" 023+24+ssh_as ana release get dami/secret v1 tool >"$T/got" 2>"$T_LOG" || fail "a reader can't get it"25+ok cmp "$T/f/tool" "$T/got"26+ssh_as ana release get dami/secret v1 SHA256SUMS >"$T/sums" 2>"$T_LOG" || fail "a reader can't get SHA256SUMS"27+ssh_as ana release get dami/secret v1 SHA256SUMS.sig >"$T/sums.sig" 2>"$T_LOG" || fail "a reader can't get SHA256SUMS.sig"28+cp "$T/got" "$T/tool"29+ok "$FORGE" release verify --signers "$FORGE_ROOT/.forge/allowed_signers" --sums "$T/sums" "$T/tool"30+ok ssh_as ana release ls dami/secret31+eq "$(output)" "v1 SHA256SUMS $(wc -c <"$T/sums" | tr -d ' ') -32+v1 SHA256SUMS.sig $(wc -c <"$T/sums.sig" | tr -d ' ') -33+v1 tool $(wc -c <"$T/f/tool" | tr -d ' ') $(t_sha256 "$T/f/tool")"34+fails_with 'no release v9 in dami/secret' ssh_as ana release get dami/secret v9 tool35+fails_with 'release v1 of dami/secret has no file nope' ssh_as ana release get dami/secret v1 nope36+fails_with 'has no file \.\./x' ssh_as ana release get dami/secret v1 ../x37+38+# Anyone else: the same words as for a missing repo, byte for byte.39+for cmd in "release get dami/secret v1 tool" "release get dami/secret v9 tool" "release ls dami/secret"; do40+ fails ssh_as cat "$cmd" </dev/null41+ denied=$(output)42+ aside dami/secret43+ fails ssh_as cat "$cmd" </dev/null44+ back dami/secret45+ eq "$denied" "$(output)"46+ eq "$denied" "forge: no repo dami/secret"47+done48+49+# A public repo's: anyone gets them; once it is private, they aren't served.50+repo dami/open51+clone dami dami/open "$T/w/o"52+at "$T/w/o" commit "first"53+at "$T/w/o" git tag v154+ok push_as dami "$T/w/o" origin main55+FORGE_USER=dami ok at "$T/w/o" "$FORGE" release publish v1 "$T/f/tool"56+ssh_as cat release get dami/open v1 tool >"$T/got" 2>"$T_LOG" || fail "anyone gets a public release"57+ok cmp "$T/f/tool" "$T/got"58+exists "$FORGE_WWW/dami/open/releases/download/v1/tool"59+ok ssh_as dami private dami/open60+missing "$FORGE_WWW/dami/open"61+ok ssh_as cat help62+has "$T_LOG" 'release get <owner>/<repo> <tag> <file>'
A test/scenarios/release-publish.sh +69 -0
@@ -0,0 +1,69 @@
1+# forge release publish: a tag's files, SHA256SUMS and its signature land on2+# refs/releases/<tag> in one push with the tag, and are served from3+# /<owner>/<repo>/releases/download/<tag>/ byte for byte, as hard links to4+# the store; latest/ is the newest tag's. Publishing again replaces them.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+at "$T/w/d" commit "first"10+ok push_as dami "$T/w/d" origin main11+at "$T/w/d" git tag v112+mkdir "$T/f"13+printf 'greet\000\001\002 a binary\n' >"$T/f/greet-linux-amd64"14+chmod +x "$T/f/greet-linux-amd64"15+printf 'source\n' >"$T/f/greet-1.tar.gz"16+export FORGE_USER=dami17+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet-linux-amd64" "$T/f/greet-1.tar.gz"18+has "$T_LOG" 'remote: release v1: /dami/greet/releases/download/v1/'19+has "$T_LOG" 'release v1: published [0-9a-f]+, 2 file.*signed with'20+g=$FORGE_ROOT/dami/greet.git21+eq "$(git --git-dir="$g" rev-parse v1)" "$(git -C "$T/w/d" rev-parse v1)"22+dl=$FORGE_WWW/dami/greet/releases/download23+ok cmp "$T/f/greet-linux-amd64" "$dl/v1/greet-linux-amd64"24+ok cmp "$T/f/greet-1.tar.gz" "$dl/v1/greet-1.tar.gz"25+[ -x "$dl/v1/greet-linux-amd64" ] || fail "the binary lost its mode"26+eq "$(cat "$dl/v1/SHA256SUMS")" "$(t_sha256 "$T/f/greet-1.tar.gz") greet-1.tar.gz27+$(t_sha256 "$T/f/greet-linux-amd64") greet-linux-amd64"28+ok ssh-keygen -Y verify -f "$FORGE_ROOT/.forge/allowed_signers" -I dami -n forge-release -s "$dl/v1/SHA256SUMS.sig" <"$dl/v1/SHA256SUMS"29+eq "$(ls "$dl/v1" | tr '\n' ' ')" "SHA256SUMS SHA256SUMS.sig greet-1.tar.gz greet-linux-amd64 "30+# The commit says what it is, and was signed like a site deploy.31+eq "$(git --git-dir="$g" log -1 --format='%(trailers:key=Source-Commit,valueonly)' refs/releases/v1 | sed '/^$/d')" "$(git -C "$T/w/d" rev-parse v1)"32+eq "$(git --git-dir="$g" cat-file -p refs/releases/v1 | grep -c '^gpgsig ')" 133+34+# Hard links into the store, and latest/ links the same files.35+store=$FORGE_ROOT/.forge/store/$(git --git-dir="$g" rev-parse 'refs/releases/v1^{tree}')36+inode() { ls -i "$1" | awk '{ print $1 }'; }37+eq "$(inode "$dl/v1/greet-linux-amd64")" "$(inode "$store/greet-linux-amd64")"38+eq "$(inode "$dl/latest/greet-linux-amd64")" "$(inode "$store/greet-linux-amd64")"39+40+# v2 is newer, so latest/ moves to it; publishing v1 again replaces v1's41+# files with no history kept, and latest/ stays on v2.42+tick 360043+at "$T/w/d" commit "second"44+ok push_as dami "$T/w/d" origin main45+at "$T/w/d" git tag v246+printf 'greet v2\n' >"$T/f/greet-linux-amd64"47+ok at "$T/w/d" "$FORGE" release publish v2 "$T/f/greet-linux-amd64"48+ok cmp "$T/f/greet-linux-amd64" "$dl/latest/greet-linux-amd64"49+missing "$dl/latest/greet-1.tar.gz"50+printf 'source, fixed\n' >"$T/f/greet-1.tar.gz"51+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet-1.tar.gz"52+ok cmp "$T/f/greet-1.tar.gz" "$dl/v1/greet-1.tar.gz"53+missing "$dl/v1/greet-linux-amd64"54+eq "$(git --git-dir="$g" rev-list --count refs/releases/v1)" 155+has "$dl/latest/greet-linux-amd64" 'greet v2'56+eq "$(ls "$dl" | tr '\n' ' ')" "latest v1 v2 "57+58+# The page build swaps the repo's pages in with the downloads still there.59+build dami/greet60+ok cmp "$T/f/greet-1.tar.gz" "$dl/v1/greet-1.tar.gz"61+has "$dl/latest/greet-linux-amd64" 'greet v2'62+exists "$FORGE_WWW/dami/greet/releases/index.html"63+64+# Bad names never leave the machine.65+for n in SHA256SUMS .hidden 'a b'; do66+ cp "$T/f/greet-1.tar.gz" "$T/f/$n"67+ fails_with "file names use" at "$T/w/d" "$FORGE" release publish v2 "$T/f/$n"68+done69+fails_with "no tag v9" at "$T/w/d" "$FORGE" release publish v9 "$T/f/greet-1.tar.gz"
A test/scenarios/release-signature.sh +40 -0
@@ -0,0 +1,40 @@
1+# SHA256SUMS.sig must be a forge-release signature of SHA256SUMS by a key2+# of this forge: none, a stranger's, one for CI results, or one of other3+# text is refused. allowed_signers lets every key sign releases.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+repo dami/greet8+has "$FORGE_ROOT/.forge/allowed_signers" '^dami namespaces="forge-ci,forge-release,git" ssh-ed25519 '9+clone dami dami/greet "$T/w/d"10+at "$T/w/d" commit "first"11+at "$T/w/d" git tag v112+ok push_as dami "$T/w/d" origin main v113+ssh-keygen -q -t ed25519 -N '' -C eve -f "$T/keys/eve"14+# files [sign-with-key namespace]: a fresh release directory, signed or not.15+files() {16+ rm -rf "$T/f"17+ mkdir "$T/f"18+ printf 'greet\n' >"$T/f/greet"19+ printf '%s greet\n' "$(t_sha256 "$T/f/greet")" >"$T/f/SHA256SUMS"20+ if [ $# -gt 0 ]; then ssh-keygen -q -Y sign -n "$2" -f "$1" "$T/f/SHA256SUMS" 2>/dev/null; fi21+}22+push() { c=$(release_commit "$T/w/d" v1 "$T/f" -) && push_as dami "$T/w/d" origin "$c:refs/releases/v1"; }23+24+files25+fails_with 'release v1: no SHA256SUMS.sig' push26+files "$T/keys/eve" forge-release27+fails_with 'release v1: SHA256SUMS.sig is not a good forge-release signature by a key of this forge' push28+files "$T/keys/dami" forge-ci29+fails_with 'SHA256SUMS.sig is not a good forge-release signature' push30+files "$T/keys/dami" forge-release31+printf 'different text\n' >"$T/sig-other"32+ssh-keygen -q -Y sign -n forge-release -f "$T/keys/dami" "$T/sig-other" 2>/dev/null33+cp "$T/sig-other.sig" "$T/f/SHA256SUMS.sig"34+fails_with 'SHA256SUMS.sig is not a good forge-release signature' push35+missing "$FORGE_WWW/dami/greet/releases"36+37+# Any registered key will do, not only the pusher's.38+files "$T/keys/ana" forge-release39+ok push40+exists "$FORGE_WWW/dami/greet/releases/download/v1/greet"
A test/scenarios/release-tag.sh +40 -0
@@ -0,0 +1,40 @@
1+# A release is for a tag that exists once the push lands: its Tag trailer2+# names the ref's tag and its Source-Commit the tag's commit. The tag may3+# come in the same push, but not go in it; latest is no tag for a release.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet7+clone dami dami/greet "$T/w/d"8+at "$T/w/d" commit "first"9+ok push_as dami "$T/w/d" origin main10+first=$(git -C "$T/w/d" rev-parse HEAD)11+at "$T/w/d" commit "second"12+ok push_as dami "$T/w/d" origin main13+at "$T/w/d" git tag v114+at "$T/w/d" git tag latest15+mkdir "$T/f"16+printf 'greet\n' >"$T/f/greet"17+c=$(release_commit "$T/w/d" v1 "$T/f")18+19+fails_with 'release v1: no tag v1 here; push it with the release' push_as dami "$T/w/d" origin "$c:refs/releases/v1"20+at "$T/w/d" git tag v221+ok push_as dami "$T/w/d" origin v1 v222+fails_with 'release v2: its Tag trailer is v1, not v2' push_as dami "$T/w/d" origin "$c:refs/releases/v2"23+c2=$(R_TAG=v2 release_commit "$T/w/d" v1 "$T/f")24+fails_with 'release v1: its Tag trailer is v2, not v1' push_as dami "$T/w/d" origin "$c2:refs/releases/v1"25+c2=$(R_SRC=$first release_commit "$T/w/d" v1 "$T/f")26+fails_with "release v1: its Source-Commit is $first, but tag v1 is $(git -C "$T/w/d" rev-parse v1)" push_as dami "$T/w/d" origin "$c2:refs/releases/v1"27+c2=$(release_commit "$T/w/d" latest "$T/f")28+fails_with "release latest: tags with releases use .*aren't latest" push_as dami "$T/w/d" origin latest "$c2:refs/releases/latest"29+fails_with 'release v1: this push deletes tag v1' push_as dami "$T/w/d" origin :refs/tags/v1 "$c:refs/releases/v1"30+missing "$FORGE_WWW/dami/greet/releases"31+32+# The tag moved since the release was made: it says so.33+at "$T/w/d" git tag -f v1 "$first" >/dev/null34+fails_with 'its Source-Commit is .*, but tag v1 is' push_as dami "$T/w/d" origin -f v1 "$c:refs/releases/v1"35+# A new tag in the same push as its release.36+at "$T/w/d" git tag v337+c3=$(release_commit "$T/w/d" v3 "$T/f")38+ok push_as dami "$T/w/d" origin v3 "$c3:refs/releases/v3"39+exists "$FORGE_WWW/dami/greet/releases/download/v3/greet"40+exists "$FORGE_WWW/dami/greet/releases/download/latest/greet"
A test/scenarios/release-verify.sh +54 -0
@@ -0,0 +1,54 @@
1+# forge release verify checks downloads against SHA256SUMS, and SHA256SUMS2+# against its signature by a key of the forge, whose keys it gets from3+# forge.allowedsigners, --signers or ssh <host> signers. A changed file, a4+# rewritten SHA256SUMS, a signature by a stranger or none, or a file it5+# doesn't list, fail.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+repo dami/greet10+clone dami dami/greet "$T/w/d"11+at "$T/w/d" commit "first"12+ok push_as dami "$T/w/d" origin main13+at "$T/w/d" git tag v114+mkdir "$T/f" "$T/dl"15+printf 'greet\000binary\n' >"$T/f/greet"16+printf 'other\n' >"$T/f/other"17+FORGE_USER=dami ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"18+cp "$FORGE_WWW/dami/greet/releases/download/v1/"* "$T/dl/"19+signers=$FORGE_ROOT/.forge/allowed_signers20+21+# In a clone, forge.allowedsigners says who signs.22+ok at "$T/w/d" "$FORGE" release verify "$T/dl/greet"23+has "$T_LOG" '^ok greet: matches .*/dl/SHA256SUMS, signed by dami$'24+# Anywhere: --signers, or the forge's keys over ssh.25+ok at "$T" "$FORGE" release verify --signers "$signers" "$T/dl/greet"26+ok at "$T" env GIT_SSH_COMMAND="$T/ssh" FORGE_USER=ana "$FORGE" release verify --host "$FORGE_SSH" "$T/dl/greet"27+has "$T_LOG" 'signed by dami$'28+mkdir "$T/elsewhere"29+cp "$T/dl/greet" "$T/elsewhere/"30+ok at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"31+32+# A changed file.33+printf 'x' >>"$T/elsewhere/greet"34+fails_with 'greet: DOES NOT MATCH its sha256' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"35+# A file SHA256SUMS doesn't list.36+cp "$T/f/other" "$T/dl/other"37+fails_with 'other: not in' at "$T" "$FORGE" release verify --signers "$signers" "$T/dl/greet" "$T/dl/other"38+# SHA256SUMS rewritten to match a changed file: its signature no longer does.39+cp "$T/dl/SHA256SUMS" "$T/sums.good"40+printf '%s greet\n' "$(t_sha256 "$T/elsewhere/greet")" >"$T/dl/SHA256SUMS"41+fails_with 'SHA256SUMS.sig is not a good forge-release signature' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"42+# Signed again, by a key the forge doesn't know.43+ssh-keygen -q -t ed25519 -N '' -C eve -f "$T/keys/eve"44+rm "$T/dl/SHA256SUMS.sig"45+ssh-keygen -q -Y sign -n forge-release -f "$T/keys/eve" "$T/dl/SHA256SUMS" 2>/dev/null46+fails_with 'is not a good forge-release signature' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"47+# By dami, but for CI results, not releases.48+rm "$T/dl/SHA256SUMS.sig"49+ssh-keygen -q -Y sign -n forge-ci -f "$T/keys/dami" "$T/dl/SHA256SUMS" 2>/dev/null50+fails_with 'is not a good forge-release signature' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"51+# No signature at all.52+cp "$T/sums.good" "$T/dl/SHA256SUMS"53+rm "$T/dl/SHA256SUMS.sig"54+fails_with 'SHA256SUMS.sig, so nothing vouches' at "$T" "$FORGE" release verify --signers "$signers" "$T/dl/greet"