clone

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

Keep private repos' release trees in a store only the forge reads

cat committed 1312bf5
A private repo's release files were extracted into .forge/store and
hard-linked from there into the private root. The caddy user can read
.forge/store, since sites are served from it; nothing serves a release
tree from it, but a private repo's files shouldn't sit there. They are
now extracted into .forge/private/store, under the directory only the
forge can read, beside the private root, so the links stay on one
filesystem. Which store follows the repo's flag, as the pages root does,
so going private links the downloads from the private store and going
public from .forge/store, extracting them there. gc frees trees in both,
each keeping what its own side's release refs name (and .forge/store
what sites link to), so a flipped repo's old trees go.
commit
1312bf52289179e55b97c1d7c2ec6c644d0496a5
parent
cf1c0f1ab18a30f98ce5bd6d40a99e6500ac286f
author
cat <admin@dev.site>
ci
✓ passed in 113s, signed by cat
kind
fix risk score 1.6 of 3, Jev's estimate
review
#4 Keep private repos' release trees in a store only the forge reads
ci attestation
forge-attestation 1
type ci
commit 1312bf52289179e55b97c1d7c2ec6c644d0496a5
tree 47c95167e064f8f43c7d37a12079fbbbbd5f7bd1
script .forge/ci e6f038ae810b87f90a4be115a762a9c91dd76a01
result pass
exit 0
started 2026-10-18T22:35:03Z
duration 113s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:d13accbf6d5429367e05069488b7d4c4221b046938f6b8df8b214a2ffa02345f 237 lines
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|dd79dd4 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|dd79dd4 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|0e3103f running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|0e3103f fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|d0898e6 running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|d0898e6 pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed 86ac319 -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|183fc0b running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|183fc0b pass in 0s, signed
|pushed main
|2cde03c running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|2cde03c pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: deployed 423f2f9 -> /sites/dami/forge/guide/
|(node:25823) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.vPjB9I/wt/demo/root/.forge/www
|(node:25964) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.vPjB9I/wt/demo/root/.forge/www
|(node:26208) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.vPjB9I/wt/demo/root/.forge/www
|(node:26353) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.vPjB9I/wt/demo/root/.forge/www
|(node:27085) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.vPjB9I/wt/demo/root/.forge/www
|226 of 226 pages render the same without 11ty
|guide: 4 files, links ok
|ok

Signature: good, cat

6 files changed, +242 -42
MREADME.md45+++++++++++-----
Mbin/forge81++++++++++++++++++++++--------
Mtest/scenarios/release-private-gc.sh13+++--
Atest/scenarios/release-private-store-flip.sh47+++++++++++++++++
Atest/scenarios/release-private-store-gc.sh68+++++++++++++++++++++++++
Atest/scenarios/release-private-store.sh30+++++++++++
M README.md +31 -14
@@ -193,7 +193,8 @@ ssh git@git.example.com ls # yours, and the private o
193193 The owner and collaborators flip a repo, the way Forgejo does. `private`194194 removes the flag, the repo's public pages, its entries on the index, owner195195 pages and `repos.json`, and its served sites, previews and their196-hostnames, moves its release downloads into the private root, and builds196+hostnames, moves its release downloads into the private root (and their197+files into the private store, see [Releases](#releases)), and builds197198 its pages again there. Then it starts198199 a mirror sync, which deletes them from the bucket. Clones, caches and copies199200 people already have can't be recalled. `public` first runs the secret check
@@ -484,7 +485,8 @@ The secret check skips releases. It reads branches and tags, and a
484485 release's source is its tag.485486 486487 `post-receive` extracts the tree once into `.forge/store/<tree>/`, as for a487-site, and hard-links its files into488+site (a private repo's into a store of its own, below), and hard-links its489+files into488490 `/<owner>/<repo>/releases/download/<tag>/` (a `/` in the tag becomes `~`).489491 `latest/` next to them holds the files of the newest tag that has a490492 release, so `/dami/greet/releases/download/latest/greet-linux-amd64` keeps
@@ -508,10 +510,22 @@ doesn't see them and `forge.publicsites` doesn't change that. The private
508510 server gives them to its readers, signed in or showing a token (see509511 [Private pages](#private-pages)) as Caddy gives a public repo's510512 (attachments, uncompressed, byte ranges) but `Cache-Control: private,511-no-cache`, and anyone else gets the 404 of a file that isn't there. A repo512-that goes private takes its downloads there at once, and one that goes513-public brings them back to `www`. Readers can also get them over ssh, and to514-anyone else these commands answer as for a missing repo:513+no-cache`, and anyone else gets the 404 of a file that isn't there.514+515+Their files aren't extracted into `.forge/store` either, but into516+`.forge/private/store/<tree>/`, and linked from there. `.forge/store` is517+readable by the caddy user, which serves sites from it; nothing serves a518+release tree from a store, but a private repo's files shouldn't sit where519+anything but the forge can read them, so they live under `.forge/private`,520+which only git can read (mode 700). Both stores are under `.forge`, so the521+downloads stay hard links on one filesystem. A repo that goes private takes522+its downloads to the private root at once, linked from the private store523+(extracted there), and one that goes public brings them back to `www`,524+linked from `.forge/store` (extracted there again); `forge gc` frees the525+trees left in the store it no longer uses.526+527+Readers can also get them over ssh, and to anyone else these commands528+answer as for a missing repo:515529 516530 ```sh517531 curl -fO -u ana:<token> https://git.example.com/dami/notes/releases/download/v1.0/tool # token --repo dami/notes
@@ -800,14 +814,17 @@ push (log: `.forge/build.log`); `forge build` rebuilds everything.
800814 801815 ### Cleaning up802816 803-Every distinct deploy is extracted once into `.forge/store/<tree>/`, and the804-old tree stays when a newer deploy takes its link. `forge gc` removes store805-trees that no `www/sites` or `www/previews` link points at (hostnames point806-at those links) and no `refs/releases/*` ref names, a private repo's too807-(release files are hard links, which name no tree), hostnames whose deploy808-is gone, previews of review branches that no longer exist, and the809-downloads of releases whose ref was deleted without the hooks, in `www` or810-the private root. A deploy extracts its tree before it links it, and817+Every distinct deploy is extracted once into `.forge/store/<tree>/` (a818+private repo's releases into `.forge/private/store/<tree>/`), and the old819+tree stays when a newer deploy takes its link. `forge gc` removes trees in820+`.forge/store` that no `www/sites` or `www/previews` link points at821+(hostnames point at those links) and no public repo's `refs/releases/*` ref822+names, and trees in `.forge/private/store` that no private repo's release823+ref names (release files are hard links, which name no tree), so a repo824+that flipped leaves nothing in the store it left. It also removes hostnames825+whose deploy is gone, previews of review branches that no longer exist,826+and the downloads of releases whose ref was deleted without the hooks, in827+`www` or the private root. A deploy extracts its tree before it links it, and811828 touches a tree it reuses, so store entries changed in the last 10 minutes812829 are kept (`--age <minutes>`), which makes gc safe to run while people push. `--dry-run` lists813830 what it would remove. A release published again leaves its old files in the
M bin/forge +59 -22
@@ -962,6 +962,8 @@ load_config() {
962962 # Private repos' pages: the same layout as www, served only to their963963 # readers (lib/private-server.ts).964964 FORGE_PRIVATE_WWW=${FORGE_PRIVATE_WWW:-$FORGE_STATE/private/www}965+ # Their release trees, beside it (release_store).966+ FORGE_PRIVATE_STORE=$(dirname "$FORGE_PRIVATE_WWW")/store965967 FORGE_AUTHORIZED_KEYS=${FORGE_AUTHORIZED_KEYS:-$FORGE_ROOT/.ssh/authorized_keys}966968 export FORGE_ROOT FORGE_STATE FORGE_WWW FORGE_PRIVATE_WWW967969 }
@@ -1738,7 +1740,8 @@ sc_private() {
17381740 rm -f "$RDIR/git-daemon-export-ok"17391741 rm -rf "${FORGE_WWW:?}/$1"17401742 # Its release downloads go with its pages, and are in the private root1741- # for its readers now rather than after the build.1743+ # for its readers now rather than after the build, linked from the1744+ # private store (release_store); gc frees the trees it left in .forge/store.17421745 (17431746 export GIT_DIR="$RDIR"17441747 OWNER=${1%%/*} NAME=${1#*/}
@@ -1785,6 +1788,8 @@ sc_public() {
17851788 esac17861789 touch "$RDIR/git-daemon-export-ok"17871790 rm -rf "${FORGE_PRIVATE_WWW:?}/$1"1791+ # Release downloads are linked from .forge/store again (release_store);1792+ # gc frees the repo's trees in the private store.17881793 (17891794 export GIT_DIR="$RDIR"17901795 OWNER=${1%%/*} NAME=${1#*/}
@@ -2218,14 +2223,15 @@ push_site() {
22182223 fi22192224 }22202225 2221-# store_tree <commit>: extract its tree once into the content store,2222-# .forge/store/<tree>/, and print where. Sites link to it (deploy_site),2223-# releases hard-link its files (release_link). git archive stamps every file2226+# store_tree <commit> [<store>]: extract its tree once into the content2227+# store, .forge/store/<tree>/, or into <store>/<tree>/, and print where.2228+# Sites link to it (deploy_site), releases hard-link its files from the2229+# store release_store names (release_link). git archive stamps every file22242230 # with the commit's time, so a tree extracted again (after a restore) gets22252231 # the same mtimes. A reused tree counts as new, so a concurrent forge gc,22262232 # which spares what changed in its last --age minutes, leaves it alone.22272233 store_tree() {2228- st_dir=$FORGE_STATE/store/$(git rev-parse "$1^{tree}")2234+ st_dir=${2:-$FORGE_STATE/store}/$(git rev-parse "$1^{tree}")22292235 if [ ! -d "$st_dir" ]; then22302236 rm -rf "$st_dir.tmp.$$"22312237 mkdir -p "$st_dir.tmp.$$"
@@ -2238,17 +2244,18 @@ store_tree() {
22382244 22392245 # release_link <root> <tag> <commit|ZERO>: make22402246 # <root>/<owner>/<repo>/releases/download/<tag>/ hard links to the files of2241-# a release, extracted once into the store, or remove it. Hard links, not a2242-# symlink like a site's: rclone (forge mirror) skips symlinks, and the files2243-# keep the store's mtime. Runs in a repo (GIT_DIR) with OWNER and NAME set;2244-# <root> is the repo's pages root (pages_root) or a build's output.2247+# a release, extracted once into the repo's store (release_store), or remove2248+# it. Hard links, not a symlink like a site's: rclone (forge mirror) skips2249+# symlinks, and the files keep the store's mtime. Runs in a repo (GIT_DIR)2250+# with OWNER and NAME set; <root> is the repo's pages root (pages_root) or a2251+# build's output.22452252 release_link() {22462253 rl=$1/$OWNER/$NAME/releases/download/$(enc_ref "$2")22472254 if [ "$3" = "$ZERO" ]; then22482255 rm -rf "$rl"22492256 return 022502257 fi2251- rs=$(store_tree "$3")2258+ rs=$(store_tree "$3" "$(release_store)")22522259 rm -rf "$rl.tmp.$$"22532260 link_files "$rl.tmp.$$" "$rs"/*22542261 swap "$rl.tmp.$$" "$rl"
@@ -2292,6 +2299,24 @@ private_www() {
22922299 chmod 700 "$(dirname "$FORGE_PRIVATE_WWW")"22932300 }22942301 2302+# release_store: where the repo's release trees are extracted, to be2303+# hard-linked into its pages. A public repo's go in the content store, which2304+# the caddy user can read, since sites are served from it. A private repo's2305+# go in FORGE_PRIVATE_STORE, beside the private root under the directory2306+# only the forge can read: nothing serves a store, but those files are a2307+# private repo's all the same. Both are under .forge, so the links stay on2308+# one filesystem. Like pages_root it follows the repo's flag, so a flip2309+# links the downloads from the store of the side the repo goes to,2310+# extracting them there. Runs in a repo (GIT_DIR).2311+release_store() {2312+ if [ -f "$GIT_DIR/git-daemon-export-ok" ]; then2313+ printf '%s' "$FORGE_STATE/store"2314+ else2315+ private_www2316+ printf '%s' "$FORGE_PRIVATE_STORE"2317+ fi2318+}2319+22952320 # pages_root <git-dir>: where the repo's pages and release downloads live:22962321 # www for a public repo, the private root for a private one.22972322 pages_root() {
@@ -2613,7 +2638,8 @@ was_review() { grep -qxF "branch $2" "$1"/forge/reviews/* 2>/dev/null; }
26132638 # branches that are gone (deleted outside the hook, or before it removed26142639 # previews), hostnames whose deploy is gone, downloads of deleted releases,26152640 # and store trees that no site or preview links to (hostnames point at those2616-# links, never at the store) and no release ref names.2641+# links, never at the store) and no release ref names, in the content store2642+# and the private store (release_store), each keeping its own side's.26172643 # A deploy extracts its tree before linking it, so entries changed in the26182644 # last --age minutes stay. Then repacks every repo (repack_repo).26192645 cmd_gc() {
@@ -2677,27 +2703,38 @@ cmd_gc() {
26772703 # Candidates before links: a deploy that links a tree after this looked26782704 # has touched it first, so it can't be a candidate.26792705 n=0 kb=02680- if [ -d "$FORGE_STATE/store" ]; then2681- set --2682- [ "$age" = 0 ] || set -- -mmin +"$age"2683- old=$(find "$FORGE_STATE/store" -mindepth 1 -maxdepth 1 "$@")2684- used=$(for l in "$FORGE_WWW"/sites/*/*/* "$FORGE_WWW"/previews/*/*/*/*; do if [ -L "$l" ]; then basename "$(readlink "$l")"; fi; done2685- # Release files are hard links, which don't name their tree: the refs do.2686- for d in $repos; do git --git-dir="$FORGE_ROOT/$d" for-each-ref --format='%(tree)' refs/releases/; done)2706+ set --2707+ [ "$age" = 0 ] || set -- -mmin +"$age"2708+ for st in "$FORGE_STATE/store" "$FORGE_PRIVATE_STORE"; do2709+ [ -d "$st" ] || continue2710+ pub=1 what=store2711+ [ "$st" = "$FORGE_STATE/store" ] || pub=0 what='private store'2712+ old=$(find "$st" -mindepth 1 -maxdepth 1 "$@")2713+ used=$(if [ "$pub" = 1 ]; then for l in "$FORGE_WWW"/sites/*/*/* "$FORGE_WWW"/previews/*/*/*/*; do if [ -L "$l" ]; then basename "$(readlink "$l")"; fi; done; fi2714+ # Release files are hard links, which don't name their tree: the2715+ # refs do, of the repos on this store's side. A repo that flipped2716+ # links from the other store now (release_store), so its trees2717+ # here go.2718+ for d in $repos; do2719+ p=02720+ [ ! -f "$FORGE_ROOT/$d/git-daemon-export-ok" ] || p=12721+ [ "$p" = "$pub" ] || continue2722+ git --git-dir="$FORGE_ROOT/$d" for-each-ref --format='%(tree)' refs/releases/2723+ done)26872724 for s in $old; do26882725 printf '%s\n' "$used" | grep -qxF "${s##*/}" && continue26892726 k=$(du -sk "$s" | cut -f1)26902727 [ "$dry" = 1 ] || rm -rf "$s"2691- echo "$did store ${s##*/} (${k}K)"2728+ echo "$did $what ${s##*/} (${k}K)"26922729 n=$((n + 1)) kb=$((kb + k))26932730 done2694- fi2731+ done26952732 if [ "$dry" = 1 ]; then2696- echo "gc: would remove $n from the store (${kb}K); repacking skipped"2733+ echo "gc: would remove $n from the stores (${kb}K); repacking skipped"26972734 return26982735 fi26992736 for d in $repos; do repack_repo "$FORGE_ROOT/$d" || say "forge: repacking $d failed"; done2700- echo "gc: removed $n from the store (${kb}K)"2737+ echo "gc: removed $n from the stores (${kb}K)"27012738 }27022739 27032740 # Geometric repacking merges packs only until each is at least twice the
M test/scenarios/release-private-gc.sh +7 -6
A test/scenarios/release-private-store-flip.sh +47 -0
@@ -0,0 +1,47 @@
1+# A flip moves a repo's release trees with its downloads: going public2+# extracts them into .forge/store and links www's from there, going private3+# into .forge/private/store and links the private root's from there. No4+# download is left a link into the other side's store, and gc frees the5+# trees the flip left behind there.6+. "$(dirname "$0")/../lib.sh"7+user dami8+repo --private dami/greet9+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 main13+mkdir "$T/f"14+printf 'greet 1\n' >"$T/f/greet"15+FORGE_USER=dami ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"16+tree=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse 'refs/releases/v1^{tree}')17+pub=$FORGE_ROOT/.forge/store/$tree18+priv=$FORGE_ROOT/.forge/private/store/$tree19+w=$FORGE_WWW/dami/greet/releases/download20+p=$FORGE_ROOT/.forge/private/www/dami/greet/releases/download21+inode() { ls -i "$1" | awk '{ print $1 }'; }22+missing "$pub"23+eq "$(inode "$p/v1/greet")" "$(inode "$priv/greet")"24+25+ok ssh_as dami public dami/greet26+missing "$p"27+eq "$(inode "$w/v1/greet")" "$(inode "$pub/greet")"28+eq "$(inode "$w/latest/greet")" "$(inode "$pub/greet")"29+build dami/greet30+eq "$(inode "$w/v1/greet")" "$(inode "$pub/greet")"31+ok forge gc --age 032+has "$T_LOG" "^removed private store $tree "33+lacks "$T_LOG" "^removed store "34+missing "$priv"35+has "$w/v1/greet" 'greet 1'36+37+ok ssh_as dami private dami/greet38+missing "$FORGE_WWW/dami/greet"39+eq "$(inode "$p/v1/greet")" "$(inode "$priv/greet")"40+eq "$(inode "$p/latest/greet")" "$(inode "$priv/greet")"41+build dami/greet42+eq "$(inode "$p/v1/greet")" "$(inode "$priv/greet")"43+ok forge gc --age 044+has "$T_LOG" "^removed store $tree "45+lacks "$T_LOG" "^removed private store "46+missing "$pub"47+has "$p/v1/greet" 'greet 1'
A test/scenarios/release-private-store-gc.sh +68 -0
@@ -0,0 +1,68 @@
1+# forge gc frees unused trees in both stores: .forge/store keeps what sites2+# link to and public repos' releases name, .forge/private/store what private3+# repos' releases name. Fresh trees wait out the grace (--age), --dry-run4+# only lists, and a removed private repo's trees go with it while its5+# trash is purged on time.6+. "$(dirname "$0")/../lib.sh"7+user dami8+repo dami/open9+repo --private dami/secret10+mkdir "$T/f"11+export FORGE_USER=dami12+for r in open secret; do13+ clone dami "dami/$r" "$T/w/$r"14+ for v in 1 2; do15+ tick 360016+ at "$T/w/$r" commit "v$v"17+ at "$T/w/$r" git tag "v$v"18+ ok push_as dami "$T/w/$r" origin main19+ printf '%s %s\n' "$r" "$v" >"$T/f/tool"20+ ok at "$T/w/$r" "$FORGE" release publish "v$v" "$T/f/tool"21+ eval "${r}$v=$(git --git-dir="$FORGE_ROOT/dami/$r.git" rev-parse "refs/releases/v$v^{tree}")"22+ done23+done24+ok push_as dami "$T/w/open" origin "$(site_commit "$T/w/open" docs):refs/sites/docs"25+unset FORGE_USER26+site=$(basename "$(readlink "$FORGE_WWW/sites/dami/open/docs")")27+store=$FORGE_ROOT/.forge/store28+priv=$FORGE_ROOT/.forge/private/store29+30+# Everything is in use.31+ok forge gc --age 032+lacks "$T_LOG" 'removed (release|store|private store)'33+for t in "$open1" "$open2" "$site"; do exists "$store/$t"; done34+for t in "$secret1" "$secret2"; do exists "$priv/$t"; done35+36+# Dead trees in each: kept while fresh, listed by --dry-run, then freed.37+git --git-dir="$FORGE_ROOT/dami/open.git" update-ref -d refs/releases/v138+git --git-dir="$FORGE_ROOT/dami/secret.git" update-ref -d refs/releases/v139+ok forge gc40+lacks "$T_LOG" 'removed (store|private store)'41+exists "$store/$open1"42+exists "$priv/$secret1"43+ok forge gc --age 0 --dry-run44+has "$T_LOG" "^would remove store $open1 "45+has "$T_LOG" "^would remove private store $secret1 "46+exists "$store/$open1"47+exists "$priv/$secret1"48+ok forge gc --age 049+has "$T_LOG" "^removed store $open1 "50+has "$T_LOG" "^removed private store $secret1 "51+has "$T_LOG" '^gc: removed 2 from the stores'52+missing "$store/$open1"53+missing "$priv/$secret1"54+for t in "$open2" "$site"; do exists "$store/$t"; done55+exists "$priv/$secret2"56+57+# A removed private repo: its downloads go at once, its tree at gc, its58+# trash after FORGE_TRASH_DAYS.59+ok forge repo rm dami/secret60+missing "$FORGE_ROOT/.forge/private/www/dami/secret"61+ok forge gc --age 062+has "$T_LOG" "^removed private store $secret2 "63+lacks "$T_LOG" '^removed trash'64+missing "$priv/$secret2"65+ok env FORGE_TRASH_DAYS=0 "$FORGE" gc --age 066+has "$T_LOG" '^removed trash dami/secret\.git\.'67+eq "$(find "$FORGE_ROOT/.forge/trash" -mindepth 2 | wc -l | tr -d ' ')" 068+for t in "$open2" "$site"; do exists "$store/$t"; done
A test/scenarios/release-private-store.sh +30 -0