A private repo's pages are built like a public repo's, with FORGE_PRIVATE set, into .forge/private/www (mode 700): the same layout as www, so the pages, search index and code index sit at the same paths, and they link the same hashed assets, which the build adds to www/assets as any build does. The private build's index and owner pages are dropped, and the public build still leaves private repos out of the index, owner pages and repos.json. Nothing serves the new root yet. Pages move with visibility: private builds the repo into the private root, public empties it there, a build of one repo moves its pages to the root its flag says, and a full build drops private pages of repos that are public or gone. Private pages carry a "private" mark in the header, and the clone panel says HTTPS takes a token.
- commit
- 244787abfb892215ef648f0a06415435f0657488
- parent
- a7d5191463dc80594059f93472781cd544919f55
- author
- cat <admin@dev.site>
- kind
- feat risk score 2.1 of 3, Jev's estimate
| M | bin/ | 87 | ++++++++++++++++++++++++------ |
| M | eleventy.config.ts | 8 | ++- |
| M | lib/ | 17 | ++++-- |
| M | lib/ | 3 | +- |
| M | site/ | 3 | +- |
| M | site/ | 3 | + |
| M | test/ | 20 | ++++++- |
| M | test/ | 38 | ++++++++++++- |
@@ -956,8 +956,11 @@ load_config() {
956956 fi957957 FORGE_STATE=$FORGE_ROOT/.forge958958 FORGE_WWW=${FORGE_WWW:-$FORGE_STATE/www}959+ # Private repos' pages: the same layout as www, served only to their960+ # readers (lib/private-server.ts).961+ FORGE_PRIVATE_WWW=${FORGE_PRIVATE_WWW:-$FORGE_STATE/private/www}959962 FORGE_AUTHORIZED_KEYS=${FORGE_AUTHORIZED_KEYS:-$FORGE_ROOT/.ssh/authorized_keys}960- export FORGE_ROOT FORGE_STATE FORGE_WWW963+ export FORGE_ROOT FORGE_STATE FORGE_WWW FORGE_PRIVATE_WWW961964 }962965 963966 valid_name() {@@ -1622,7 +1625,8 @@ redeploy_sites() {
16221625 # from now on. Its pages go, the index, owner pages and repos.json are16231626 # rebuilt without it, its sites, previews and their hostnames go unless16241627 # forge.publicsites, and the mirror's next sync deletes them from the bucket.1625-# The events server stops streaming it at once (it checks the flag).1628+# Its pages are built again into the private root, for its readers. The1629+# events server streams it only to them from now on (it checks the flag).16261630 sc_private() {16271631 [ $# -eq 1 ] || die "usage: private <owner>/<repo>"16281632 open_admin "$1"@@ -1639,13 +1643,14 @@ sc_private() {
16391643 # without this one after the build.16401644 rm -f "$FORGE_WWW"/insights/*.json "$FORGE_WWW"/insights/*.parquet16411645 rebuild - private1646+ rebuild "$1" private16421647 mirror_kick1643- echo "$1 is private: only ${1%%/*}, its collaborators and its readers (readers $1 add <user>) can clone it."1644- echo "Its pages and sites are gone from here, and from the mirror at its next sync. Clones and copies people already have can't be recalled."1648+ echo "$1 is private: only ${1%%/*}, its collaborators and its readers (readers $1 add <user>) can clone it, and see its pages once they sign in."1649+ echo "Its public pages and sites are gone from here, and from the mirror at its next sync. Clones and copies people already have can't be recalled."16451650 }16461651 16471652 # public <owner>/<repo> [--force]: anyone can clone it and its pages come1648-# back. All of its history becomes public at once, so the secret check reads1653+# back, built into www; its private pages go. All of its history becomes public at once, so the secret check reads16491654 # every commit on its branches and tags first, not only what a push adds,16501655 # and refuses unless --force.16511656 sc_public() {@@ -1667,6 +1672,7 @@ sc_public() {
16671672 ;;16681673 esac16691674 touch "$RDIR/git-daemon-export-ok"1675+ rm -rf "${FORGE_PRIVATE_WWW:?}/$1"16701676 (16711677 export GIT_DIR="$RDIR"16721678 OWNER=${1%%/*} NAME=${1#*/}@@ -2249,8 +2255,9 @@ write_graph() {
22492255 22502256 # Without arguments, rebuild repo by repo so memory stays bounded by the22512257 # largest repo, then drop pages for repos and owners that no longer exist,2252-# and for repos that are private now (and their sites, unless2253-# forge.publicsites), so a flag removed by hand takes effect here too.2258+# public pages of repos that are private now (and their sites, unless2259+# forge.publicsites) and private pages of repos that are public now, so a2260+# flag added or removed by hand takes effect here too.22542261 build_all() {22552262 # Same order as the pages list them: ignoring case.22562263 repos=$(cd "$FORGE_ROOT" && for d in */*.git; do [ -f "$d/HEAD" ] && printf '%s\n' "${d%.git}"; done | sort -f)@@ -2276,19 +2283,63 @@ build_all() {
22762283 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"22772284 done22782285 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi2286+ # Private pages of repos that are gone, or public now.2287+ for w in "$FORGE_PRIVATE_WWW"/*/*/; do2288+ [ -d "$w" ] || continue2289+ slug=${w#"$FORGE_PRIVATE_WWW"/}2290+ slug=${slug%/}2291+ [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ ! -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"2292+ done2293+ for w in "$FORGE_PRIVATE_WWW"/*/; do2294+ if [ -d "$w" ]; then rmdir "$w" 2>/dev/null || true; fi2295+ done2296+}2297+2298+# build_private <owner>/<repo>: a private repo's pages, built like a public2299+# repo's but into FORGE_PRIVATE_WWW, with the same layout, which only the2300+# private server reads (to the repo's readers). The pages link the same2301+# hashed assets, so the build's assets go into www/assets as any build's2302+# do; they are templates' CSS and the forge's scripts, nothing of the repo.2303+# The rest of what the build writes (index and owner pages) is dropped.2304+# Runs in a subshell of cmd_build, for its EXIT trap.2305+build_private() {2306+ only=$12307+ lock=$FORGE_STATE/build.lock2308+ n=02309+ until mkdir "$lock" 2>/dev/null; do2310+ n=$((n + 1))2311+ [ "$n" -lt 600 ] || die "build lock held: $lock"2312+ sleep 12313+ done2314+ out=$FORGE_STATE/build.$$2315+ trap 'rm -rf "$out"; rmdir "$lock" 2>/dev/null' EXIT2316+ rm -rf "$out"2317+ t0=$(date +%s)2318+ write_graph "$FORGE_ROOT/$only.git"2319+ mkdir -p "$FORGE_PRIVATE_WWW"2320+ chmod 700 "$(dirname "$FORGE_PRIVATE_WWW")"2321+ (cd "$FORGE_HOME" && FORGE_PRIVATE=1 FORGE_ONLY=$only FORGE_WWW=$out FORGE_LIVE_WWW=$FORGE_PRIVATE_WWW FORGE_PUBLIC_WWW=$FORGE_WWW \2322+ FORGE_BUILD_STARTED=$((t0 * 1000)) FORGE_BUILD_REASON=${FORGE_BUILD_REASON:-manual} \2323+ "$FORGE_HOME/node_modules/.bin/eleventy" --config=eleventy.config.ts --quiet --output="$out")2324+ mkdir -p "$FORGE_WWW"2325+ swap "$out/assets" "$FORGE_WWW/assets"2326+ # Made public while this was building: no private pages after all.2327+ if [ -d "$out/$only" ] && [ ! -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ]; then swap "$out/$only" "$FORGE_PRIVATE_WWW/$only"2328+ else rm -rf "${FORGE_PRIVATE_WWW:?}/$only"; fi2329+ say "built $only (private) in $(($(date +%s) - t0))s -> $FORGE_PRIVATE_WWW"22792330 }22802331 22812332 cmd_build() {22822333 load_config22832334 [ $# -gt 0 ] || { build_all; return; }22842335 only=$12285- # A private repo gets no pages, only its commit-graph. One that still has2286- # pages (made private by hand, say) is built: the build leaves it out, so2287- # its pages go and the index and owner pages are redone without it.2288- if [ "$only" != - ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ] && [ ! -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ] && [ ! -e "$FORGE_WWW/$only" ]; then2289- write_graph "$FORGE_ROOT/$only.git"2290- say "$only is private: no pages to build"2291- return 02336+ # A private repo's pages go to the private root. One that still has2337+ # public pages (made private by hand, say) is built here too: the public2338+ # build leaves it out, so those go and the index and owner pages are2339+ # redone without it.2340+ if [ "$only" != - ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ] && [ ! -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ]; then2341+ (build_private "$only")2342+ [ -e "$FORGE_WWW/$only" ] || return 022922343 fi22932344 lock=$FORGE_STATE/build.lock22942345 n=0@@ -2328,9 +2379,11 @@ cmd_build() {
23282379 swap "$e" "$FORGE_WWW/$b"23292380 fi23302381 done2331- # Made private while this was building: its pages go after all.2332- if [ "$only" != - ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ] && [ ! -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ]; then2333- rm -rf "${FORGE_WWW:?}/$only"2382+ # Made private while this was building: its pages go after all. Public:2383+ # its private pages go, if it had any.2384+ if [ "$only" != - ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ]; then2385+ if [ -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ]; then rm -rf "${FORGE_PRIVATE_WWW:?}/$only"2386+ else rm -rf "${FORGE_WWW:?}/$only"; fi23342387 fi23352388 if [ -f "$FORGE_STATE/events.jsonl" ] && [ "$(wc -c <"$FORGE_STATE/events.jsonl")" -gt 1048576 ]; then23362389 tail -n 1000 "$FORGE_STATE/events.jsonl" >"$FORGE_STATE/events.jsonl.tmp" && mv "$FORGE_STATE/events.jsonl.tmp" "$FORGE_STATE/events.jsonl"@@ -35,7 +35,9 @@ export default function (cfg: EleventyConfig) {
3535 cfg.on('eleventy.after', ({ directories, results }) => {3636 const out = directories.output;3737 const forge = current();38- shipAssets(out, forge?.live ?? null, forge?.code ?? codeVersion());38+ // A private build's pages go to the private root, its assets to www's39+ // (FORGE_PUBLIC_WWW), where every page finds them.40+ shipAssets(out, process.env.FORGE_PUBLIC_WWW || (forge?.live ?? null), forge?.code ?? codeVersion());3941 if (!forge) return;4042 // Raw files (images, per-branch file lists) are bytes, not templates.4143 // Code indexes are hard links to the cache's (same filesystem), so an@@ -79,8 +81,8 @@ export default function (cfg: EleventyConfig) {
7981 const only = process.env.FORGE_ONLY;8082 if (only && only !== '-') {8183 const ev: ForgeEvent = { t: Date.now(), repo: only, type: 'built', ms: line?.ms.total ?? null, rendered: results?.length ?? 0 };82- // A private repo isn't in the build (lib/forge.ts), and its events aren't streamed.83- if (!forge.repos.some((r) => r.slug === only)) ev.private = true;84+ // A private repo's events go only to its readers (lib/events-server.ts).85+ if (process.env.FORGE_PRIVATE === '1' || !forge.repos.some((r) => r.slug === only)) ev.private = true;8486 fs.appendFileSync(path.join(forge.config.state, 'events.jsonl'), JSON.stringify(ev) + '\n');8587 }8688 });@@ -92,6 +92,8 @@ export interface RepoHead {
9292 defaultBranch: string;9393 urls: RepoUrls;9494 counts: RepoCounts;95+ // Built into the private root, for its readers only.96+ private: boolean;9597 }9698 9799 // One generated file: a page for pagination, or raw bytes.@@ -144,13 +146,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
144146 build.dataStart = performance.now();145147 const cfg = config(env);146148 const { repos: all, owners: ownerNames } = discover(cfg);147- // Private repos have no public pages: none are built, and the index, owner148- // pages and repos.json leave them out.149- const summaries = all.filter((s) => !s.private);149+ // Public and private repos are built apart. A public build leaves private150+ // repos out: no pages, and no entry on the index, owner pages or151+ // repos.json. FORGE_PRIVATE=1 builds private ones, whose pages bin/forge152+ // moves into the private root (build_private); its index and owner pages153+ // aren't used.154+ const priv = env.FORGE_PRIVATE === '1';155+ const summaries = all.filter((s) => s.private === priv);150156 const verifier = new Verifier({ allowedSigners: cfg.allowedSigners, cacheFile: path.join(cfg.cacheDir, 'verify.json') });151157 // Index and owner pages list every repo, but a single-repo build only loads152158 // one. Each repo's counts and sites are cached when it is built.153- const summaryCache = path.join(cfg.cacheDir, 'summaries.json');159+ const summaryCache = path.join(cfg.cacheDir, priv ? 'summaries-private.json' : 'summaries.json');154160 let cached: Record<string, { counts: RepoCounts; sites: SiteLink[]; topic?: string | null }> = {};155161 try {156162 cached = JSON.parse(fs.readFileSync(summaryCache, 'utf8'));@@ -189,10 +195,11 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
189195 sites: repo.sites.length,190196 wiki: repo.wiki.length,191197 },198+ private: s.private,192199 };193200 cached[s.slug] = { counts: head.counts, sites: repo.sites.map((x) => ({ name: x.name, url: x.url, date: x.history[0]?.date ?? null })), topic };194201 // Everything every page of this repo shows in its header.195- const headKey = fingerprint(code, cfg.name, cfg.baseUrl, cfg.ssh, head.description, head.defaultBranch, (head.counts.wiki ?? 0) > 0, (head.counts.sites ?? 0) > 0);202+ const headKey = fingerprint(code, cfg.name, cfg.baseUrl, cfg.ssh, head.description, head.defaultBranch, (head.counts.wiki ?? 0) > 0, (head.counts.sites ?? 0) > 0, head.private);196203 const prev = cache.pages?.fps ?? {};197204 const next: Record<string, string> = {};198205 let skippedHere = 0;@@ -33,7 +33,8 @@ export interface HostsConfig {
3333 export function hostsConfig(env: NodeJS.ProcessEnv = process.env): HostsConfig {3434 const root = path.resolve(env.FORGE_ROOT || 'demo/root');3535 return {36- www: env.FORGE_LIVE_WWW || env.FORGE_WWW || path.join(root, '.forge', 'www'),36+ // A private build's live root is the private one; hostnames are in www's.37+ www: env.FORGE_PUBLIC_WWW || env.FORGE_LIVE_WWW || env.FORGE_WWW || path.join(root, '.forge', 'www'),3738 pagesDomain: env.FORGE_PAGES_DOMAIN || null,3839 owner: env.FORGE_OWNER || null,3940 scheme: /^http:/.test(env.FORGE_BASE_URL || '') ? 'http' : 'https',@@ -33,7 +33,7 @@
3333 <a class="logo" href="/" data-key="g" title="{{ forge.config.name }}: all repositories" aria-label="{{ forge.config.name }}">{% include "logo.njk" %}</a>3434 <div class="title">3535 {% block heading %}36-{% if r %}<a href="/{{ r.owner }}/">{{ r.owner }}</a> / <a href="{{ r.urls.home.href }}" data-key="h"><b>{{ r.name }}</b></a>36+{% if r %}<a href="/{{ r.owner }}/">{{ r.owner }}</a> / <a href="{{ r.urls.home.href }}" data-key="h"><b>{{ r.name }}</b></a>{% if r.private %} <a class="private-mark" href="/private/" title="A private repo: only its readers see its pages">private</a>{% endif %}3737 {% else %}<b>{{ forge.config.name }}</b>{% endif %}3838 {% endblock %}3939 </div>@@ -46,6 +46,7 @@
4646 <div class="panel">4747 <div class="copyrow"><label>SSH</label><input readonly value="{{ r.urls.cloneSsh }}" aria-label="ssh clone url"><button type="button" data-copy="{{ r.urls.cloneSsh }}">Copy</button></div>4848 {% if r.urls.cloneHttp %}<div class="copyrow"><label>HTTPS</label><input readonly value="{{ r.urls.cloneHttp }}" aria-label="https clone url"><button type="button" data-copy="{{ r.urls.cloneHttp }}">Copy</button></div>{% endif %}49+{% if r.private %}<p class="clone-note">HTTPS takes a token: <code>ssh {{ forge.config.ssh }} token --repo {{ r.slug }}</code></p>{% endif %}4950 </div>5051 </details>5152 {% endif %}@@ -107,6 +107,9 @@ details.clone > summary { list-style: none; cursor: pointer; border: 1px solid v
107107 details.clone > summary::-webkit-details-marker { display: none; }108108 details.clone .panel { position: absolute; right: 0; top: calc(100% + 6px); z-index: 10; width: min(440px, 92vw); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); }109109 .copyrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }110+.clone-note { margin: 6px 0 0; font-size: 0.85em; color: var(--muted); }111+.private-mark { font-size: 0.75em; font-weight: 600; color: var(--warn); border: 1px solid currentColor; border-radius: 999px; padding: 0 7px; vertical-align: 2px; }112+.private-mark:hover { text-decoration: none; }110113 .copyrow label { width: 44px; color: var(--muted); font-size: 0.85em; }111114 .copyrow input { flex: 1; min-width: 0; font: 13px var(--mono); padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--subtle); color: var(--fg); }112115 button, .btn { font: inherit; font-size: 0.85em; color: var(--fg); background: var(--subtle); border: 1px solid var(--border); border-radius: 6px; padding: 3px 10px; cursor: pointer; }@@ -1,7 +1,10 @@
11 # private, then public again: going private takes the repo's pages, its22 # entries on the index, owner pages and repos.json, its sites, previews and33 # their hostnames off the forge, and the mirror's next sync sees it gone;4-# pushes meanwhile build and deploy nothing. Going public brings it all back.4+# its pages move to the private root, and pushes meanwhile build there and5+# deploy nothing. Going public brings it all back and empties the private6+# root. A flag added or removed by hand moves the pages at the repo's next7+# build.58 . "$(dirname "$0")/../lib.sh"69 user dami710 repo dami/greet "a greeter"@@ -16,6 +19,8 @@ at "$T/w/dami" commit "topic"
1619 ok push_as dami "$T/w/dami" origin dami/topic "$(site_commit "$T/w/dami" preview):refs/site-previews/docs/dami/topic"1720 build1821 w=$FORGE_WWW22+p=$FORGE_ROOT/.forge/private/www23+missing "$p/dami/greet"1924 exists "$w/dami/greet/index.html"2025 has "$w/index.html" 'dami/greet'2126 has "$w/dami/index.html" 'dami/greet'@@ -41,6 +46,8 @@ has "$w/repos.json" '"dami/other"'
4146 missing "$w/sites/dami/greet/docs"4247 missing "$w/previews/dami/greet/docs/dami~topic"4348 eq "$(ls "$w/hosts")" ""49+exists "$p/dami/greet/index.html"50+exists "$p/dami/greet/code.db"4451 n=04552 until [ -f "$T/rclone.saw" ] || [ "$n" -ge 50 ]; do sleep 0.2; n=$((n + 1)); done4653 has "$T/rclone.saw" '^other$'@@ -53,6 +60,8 @@ ok push_as dami "$T/w/dami" origin dami/topic "+$(site_commit "$T/w/dami" docs2)
5360 has "$T_LOG" 'site docs: not served, since dami/greet is private'5461 missing "$w/dami/greet"5562 missing "$w/sites/dami/greet/docs"63+exists "$p/dami/greet/reviews/1/index.html"64+has "$p/dami/greet/log/dami~topic/index.html" '>more<'5665 export FORGE_NO_BUILD=15766 build5867 missing "$w/dami/greet"@@ -69,10 +78,17 @@ has "$w/repos.json" '"dami/greet"'
6978 has "$w/sites/dami/greet/docs/index.html" 'docs2'7079 exists "$w/previews/dami/greet/docs/dami~topic/index.html"7180 eq "$(ls "$w/hosts" | sort | tr '\n' ' ')" "greet-docs-dami greet-docs-dami--dami-topic "81+missing "$p/dami/greet"7282 73-# Pages of a repo made private by hand go with the next build of it.83+# Pages of a repo made private by hand move with the next build of it.7484 export FORGE_NO_BUILD=17585 rm "$FORGE_ROOT/dami/greet.git/git-daemon-export-ok"7686 build dami/greet7787 missing "$w/dami/greet"7888 lacks "$w/repos.json" 'dami/greet'89+exists "$p/dami/greet/index.html"90+# And back, by hand.91+touch "$FORGE_ROOT/dami/greet.git/git-daemon-export-ok"92+build dami/greet93+exists "$w/dami/greet/index.html"94+missing "$p/dami/greet"@@ -1,5 +1,9 @@
1-# The page build leaves private repos out entirely: no pages of their own,2-# no entry on the index, owner pages or repos.json, no search index.1+# A private repo's pages are built into the private root, with the same2+# layout as www (pages, search index, code index), and nowhere in www: no3+# pages, no entry on the index, owner pages or repos.json. They link the4+# same hashed assets, which are in www/assets. Nothing else of the private5+# build (its index or owner pages) lands anywhere, and a full build drops6+# private pages of repos that are public or gone.37 . "$(dirname "$0")/../lib.sh"48 user dami59 repo --private dami/secret "hidden thing"@@ -9,7 +13,13 @@ for r in secret open; do
913 at "$T/w/$r" commit "first"1014 ok push_as dami "$T/w/$r" origin main1115 done16+# Left over from before: private pages of a repo that is public now, and of17+# one that is gone. A full build drops them.18+p=$FORGE_ROOT/.forge/private/www19+mkdir -p "$p/dami/open" "$p/gone/repo"1220 build21+missing "$p/dami/open"22+missing "$p/gone"1323 missing "$FORGE_WWW/dami/secret"1424 exists "$FORGE_WWW/dami/open/index.html"1525 for f in index.html dami/index.html repos.json; do@@ -17,8 +27,28 @@ for f in index.html dami/index.html repos.json; do
1727 has "$FORGE_WWW/$f" 'dami/open'1828 done1929 if grep -rlq 'hidden thing' "$FORGE_WWW"; then fail "the private repo's description is in www"; fi30+31+for f in index.html search.json code.db reviews.json "log/main/index.html"; do exists "$p/dami/secret/$f"; done32+has "$p/dami/secret/index.html" 'hidden thing'33+has "$p/dami/secret/index.html" 'class="private-mark" href="/private/"'34+has "$p/dami/secret/index.html" 'HTTPS takes a token: <code>ssh git@forge.test token --repo dami/secret</code>'35+lacks "$FORGE_WWW/dami/open/index.html" 'private-mark'36+eq "$(ls "$p")" dami37+eq "$(ls "$p/dami")" secret38+eq "$(stat -f %Lp "$FORGE_ROOT/.forge/private" 2>/dev/null || stat -c %a "$FORGE_ROOT/.forge/private")" 70039+for a in $(grep -rhoE '(href|src)="/assets/[^"]+"' "$p" --include=index.html | sed -E 's/^[a-z]+="//; s/"$//' | sort -u); do40+ exists "$FORGE_WWW$a"41+done42+2043 build dami/secret21-has "$T_LOG" 'dami/secret is private: no pages to build'44+has "$T_LOG" 'built dami/secret \(private\)'2245 missing "$FORGE_WWW/dami/secret"23-# Its commit-graph is still kept up for git.46+# Its commit-graph is kept up for git too.2447 exists "$FORGE_ROOT/dami/secret.git/objects/info/commit-graphs/commit-graph-chain"48+49+# A push rebuilds it there, like any repo's pages.50+sync_builds51+at "$T/w/secret" commit "second"52+ok push_as dami "$T/w/secret" origin main53+has "$p/dami/secret/index.html" 'second'54+missing "$FORGE_WWW/dami/secret"