Write access to a repo was the names in <repo>.git/forge/collaborators,
edited by hand inside the container; read access already had an ssh
command (readers). Now collaborators has one too, of the same shape:
ssh git@src.rest collaborators cat/012 add fox # rm takes it back; no argument lists them
Only the owner changes the list. Its collaborators can see it but not
change it, so nobody makes themselves the owner's equal; a collaborator
still chooses readers, as before. The name must be a user, not the owner,
and adding twice adds once. readers and collaborators share one function
(names), and the operator has both for any repo as forge readers and
forge collaborators, which an invite will use.
ssh who <owner>/<repo> lists the owner, collaborators and readers, a
role and a name a line, for anyone who can read the repo; to anyone else
a private repo's answer is a missing one's, byte for byte.- commit
- 294d4fa920a1bc4cee21bc98c4218030163f305c
- parent
- ae9b9817ce709d6c3acf2c1e4b370a64dac386b1
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 83s, signed by cat
- kind
- feat risk score 1.6 of 3, Jev's estimate
- review
- #18 People: collaborators, who, invites and users
ci attestation
forge-attestation 1 type ci commit 294d4fa920a1bc4cee21bc98c4218030163f305c tree 0c76ea867be7d3576928eb4d7dc165caf35a1379 script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T04:12:05Z duration 83s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:6d0c26ddf4cef57ae69201c7dfe5ccc6873449b719621073788c239d959daa8e 270 lines |pushed wip/i18n |b8cce4b running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |b8cce4b pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |34a634b running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |34a634b fail in 1s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |059d52e running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |059d52e 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 965180b -> /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 |1dba008 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |1dba008 pass in 0s, signed |pushed main |e5037b0 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |e5037b0 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: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed eaf8b12 -> /sites/dami/forge/guide/ |(node:80640) 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.4ODUpb/wt/demo/root/.forge/www |(node:80767) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4ODUpb/wt/demo/root/.forge/www |(node:80908) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4ODUpb/wt/demo/root/.forge/www |(node:81044) 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 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4ODUpb/wt/demo/root/.forge/www |(node:81711) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4ODUpb/wt/demo/root/.forge/www |234 of 234 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | README.md | 13 | ++++ |
| M | bin/ | 93 | ++++++++++++++++++++++++------ |
| A | test/ | 43 | ++++++++++++++ |
| A | test/ | 26 | ++++++++ |
| A | test/ | 15 | +++++ |
@@ -138,6 +138,18 @@ users who can read it can push branches and previews under their own name
138138 (`ana/...`) and add CI notes, which must fast-forward. Nobody can force-push139139 `main` unless `forge.allowforce` is set.140140 141+```sh142+ssh git@git.example.com collaborators dami/notes add ana # rm to take it back; no argument lists them143+ssh git@git.example.com who dami/notes # its owner, collaborators and readers144+```145+146+Only the owner changes a repo's collaborators; its collaborators can list147+them, and no one else can. The name must be a user of the forge. `who` is148+for anyone who can read the repo, so a private repo's is the same `no repo`149+as a missing one's to everyone else. The operator has the same lists for150+any repo: `forge collaborators <owner>/<repo> [add|rm <user>]`, and `forge151+readers`.152+141153 `ssh git@git.example.com new <name>` creates a repo under your name, and so142154 does pushing to one that doesn't exist yet: `forge shell` sees the push before143155 git does, creates the repo the same way (same name rules, hooks installed),@@ -187,6 +199,7 @@ repos.
187199 188200 ```sh189201 ssh git@git.example.com readers dami/notes add ana # rm to take it back; no argument lists them202+ssh git@git.example.com who dami/notes # who reads it, and who may push it190203 ssh git@git.example.com private dami/greet191204 ssh git@git.example.com public dami/notes [--force]192205 ssh git@git.example.com ls # yours, and the private ones you may read@@ -21,6 +21,7 @@
2121 # Server commands run as the git user (see README.md):2222 # forge server init2323 # forge user add <name> <pubkey-file|key|-> (user rm <name> revokes keys)24+# forge readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2425 # forge keys sync2526 # forge repo new [--private] <owner>/<name> [description]2627 # forge repo import [--private] <owner>/<name> <url> [description] (server-side fetch)@@ -1030,7 +1031,7 @@ cmd_server_init() {
10301031 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/10311032 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/10321033 EOF1033- for c in new ls whoami signers site-url reviews comment approve approvals land private public readers release rm passkey passkeys sessions token tokens help; do1034+ for c in new ls whoami signers site-url reviews comment approve approvals land private public readers collaborators who release rm passkey passkeys sessions token tokens help; do10341035 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10351036 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10361037 done@@ -1862,36 +1863,87 @@ sc_public() {
18621863 echo "$1 is public: anyone can clone it, and its pages and sites are back once the build finishes."18631864 }18641865 1865-# readers <owner>/<repo> [add|rm <user>]: who may read it while it is1866-# private, besides its owner and collaborators; forge/readers, one name a line.1867-sc_readers() {1868- [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: readers <owner>/<repo> [add|rm <user>]"1869- open_admin "$1"1870- f=$RDIR/forge/readers1871- if [ $# -eq 1 ]; then1866+# names readers|collaborators <owner>/<repo> [add|rm <user>]: RDIR's1867+# forge/readers or forge/collaborators, one name a line: the list, or a1868+# user of the forge added or taken off it. For the ssh commands below and1869+# the operator's forge readers and forge collaborators.1870+names() {1871+ k=$1 slug=$2 f=$RDIR/forge/$11872+ if [ $# -eq 2 ]; then18721873 if [ -f "$f" ]; then cat "$f"; fi18731874 return 018741875 fi1875- who=$31876- case $2 in1877- add)1876+ who=$41877+ case $k:$3 in1878+ *:add)18781879 valid_name "$who" && awk -v w="$who" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || die "no user $who"1879- if [ -f "$f" ] && grep -qxF -- "$who" "$f"; then echo "$who already reads $1"; return 0; fi1880+ [ "$k" = readers ] || [ "$who" != "${slug%%/*}" ] || die "$who owns $slug"1881+ if [ -f "$f" ] && grep -qxF -- "$who" "$f"; then1882+ if [ "$k" = readers ]; then echo "$who already reads $slug"; else echo "$who already collaborates on $slug"; fi1883+ return 01884+ fi18801885 mkdir -p "$RDIR/forge"18811886 printf '%s\n' "$who" >>"$f"1882- if [ -f "$RDIR/git-daemon-export-ok" ]; then echo "$who reads $1 (anyone can while it is public; this counts once it is private)"1883- else echo "$who can read $1"; fi1887+ if [ "$k" = collaborators ]; then echo "$who collaborates on $slug: they can push anything to it, and read it while it is private"1888+ elif [ -f "$RDIR/git-daemon-export-ok" ]; then echo "$who reads $slug (anyone can while it is public; this counts once it is private)"1889+ else echo "$who can read $slug"; fi18841890 ;;1885- rm)1886- [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a reader of $1"1891+ readers:rm | collaborators:rm)1892+ [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a ${k%s} of $slug"18871893 grep -vxF -- "$who" "$f" >"$f.tmp" || true18881894 mv "$f.tmp" "$f"1889- echo "$who no longer reads $1 (a clone they have stays theirs)"1895+ if [ "$k" = readers ]; then echo "$who no longer reads $slug (a clone they have stays theirs)"1896+ else echo "$who no longer collaborates on $slug: they push only branches under $who/ now, like anyone who reads it"; fi18901897 ;;1891- *) die "usage: readers <owner>/<repo> [add|rm <user>]" ;;1898+ *) die "usage: $k <owner>/<repo> [add|rm <user>]" ;;18921899 esac18931900 }18941901 1902+# readers <owner>/<repo> [add|rm <user>]: who may read it while it is1903+# private, besides its owner and collaborators, who choose them.1904+sc_readers() {1905+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: readers <owner>/<repo> [add|rm <user>]"1906+ open_admin "$1"1907+ names readers "$@"1908+}1909+1910+# collaborators <owner>/<repo> [add|rm <user>]: who may push anything to1911+# it (main, tags, sites, releases) and read it, besides its owner. Its1912+# collaborators see the list, and only its owner changes it, so nobody1913+# makes themselves the owner's equal.1914+sc_collaborators() {1915+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: collaborators <owner>/<repo> [add|rm <user>]"1916+ open_admin "$1"1917+ [ $# -eq 1 ] || [ "$user" = "${1%%/*}" ] || die "only ${1%%/*} can change who collaborates on $1"1918+ names collaborators "$@"1919+}1920+1921+# forge readers|collaborators <owner>/<repo> [add|rm <user>]: the same1922+# lists, for the operator, whoever owns the repo.1923+cmd_names() {1924+ k=$11925+ shift1926+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: forge $k <owner>/<repo> [add|rm <user>]"1927+ load_config1928+ o=${1%%/*} n=${1#*/}1929+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$1" ] && [ -f "$FORGE_ROOT/$1.git/HEAD" ] || die "no repo $1"1930+ RDIR=$FORGE_ROOT/$1.git1931+ names "$k" "$@"1932+}1933+1934+# who <owner>/<repo>: its owner, collaborators and readers, a role and a1935+# name a line, for anyone who may read it; to anyone else a private repo1936+# isn't there (open_repo).1937+sc_who() {1938+ [ $# -eq 1 ] || die "usage: who <owner>/<repo>"1939+ open_repo "$1"1940+ printf '%-13s %s\n' owner "${1%%/*}"1941+ for k in collaborators readers; do1942+ if [ -f "$RDIR/forge/$k" ]; then grep -v '^$' "$RDIR/forge/$k" | while IFS= read -r w; do printf '%-13s %s\n' "${k%s}" "$w"; done; fi1943+ done1944+ if [ -f "$RDIR/git-daemon-export-ok" ]; then echo "(it is public: anyone can read it; readers count once it is private)"; fi1945+}1946+18951947 # rm <owner>/<repo> --yes: its owner removes it (repo_rm); collaborators18961948 # can't. Without --yes it only says what would go. A repo you can't read18971949 # isn't there (open_repo).@@ -2000,6 +2052,8 @@ cmd_shell_cmd() {
20002052 private) sc_private "$@" ;;20012053 public) sc_public "$@" ;;20022054 readers) sc_readers "$@" ;;2055+ collaborators) sc_collaborators "$@" ;;2056+ who) sc_who "$@" ;;20032057 release) sc_release "$@" ;;20042058 rm) sc_rm "$@" ;;20052059 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;@@ -2014,7 +2068,7 @@ cmd_shell_cmd() {
20142068 [ -z "$r" ] || open_repo "$r"20152069 credentials token "$@"20162070 ;;2017- *) 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\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;2071+ *) 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 collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;20182072 esac20192073 }20202074 @@ -3571,6 +3625,7 @@ server)
35713625 esac35723626 ;;35733627 user) cmd_user "$@" ;;3628+readers | collaborators) cmd_names "$cmd" "$@" ;;35743629 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;35753630 repo) cmd_repo "$@" ;;35763631 build) cmd_build "$@" ;;@@ -0,0 +1,43 @@
1+# Only a repo's owner changes its collaborators. Collaborators see the list2+# but can't change it; a reader can't see it (who shows them everyone).3+# The name must be a user and not the owner; adding twice adds once; rm of4+# someone not on it says so. The operator's forge collaborators does the5+# same for any repo.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+user bob10+user fox11+repo --private dami/secret12+ok ssh_as dami collaborators dami/secret add ana13+ok ssh_as dami readers dami/secret add bob14+15+fails_with "only dami can change who collaborates on dami/secret" ssh_as ana collaborators dami/secret add fox16+fails_with "only dami can change who collaborates on dami/secret" ssh_as ana collaborators dami/secret rm ana17+ok ssh_as ana collaborators dami/secret18+eq "$(output)" ana19+fails_with "only dami and collaborators of dami/secret" ssh_as bob collaborators dami/secret20+fails_with "^forge: no repo dami/secret$" ssh_as fox collaborators dami/secret add fox21+22+ok ssh_as dami collaborators dami/secret add ana23+eq "$(output)" "ana already collaborates on dami/secret"24+eq "$(grep -c . "$FORGE_ROOT/dami/secret.git/forge/collaborators")" 125+fails_with "no user nobody" ssh_as dami collaborators dami/secret add nobody26+fails_with "no user \\.\\./x" ssh_as dami collaborators dami/secret add ../x27+fails_with "dami owns dami/secret" ssh_as dami collaborators dami/secret add dami28+fails_with "fox is not a collaborator of dami/secret" ssh_as dami collaborators dami/secret rm fox29+fails_with "usage: collaborators" ssh_as dami collaborators dami/secret drop ana30+fails_with "usage: collaborators" ssh_as dami collaborators dami/secret add31+32+ok forge collaborators dami/secret add fox33+has "$T_LOG" '^fox collaborates on dami/secret'34+ok forge collaborators dami/secret35+eq "$(output)" "ana36+fox"37+fails_with "no repo dami/nope" forge collaborators dami/nope add fox38+39+ok ssh_as dami help40+has "$T_LOG" '^ collaborators <owner>/<repo> \[add\|rm <user>\] '41+has "$T_LOG" '^ who <owner>/<repo> '42+exists "$FORGE_ROOT/git-shell-commands/collaborators"43+exists "$FORGE_ROOT/git-shell-commands/who"@@ -0,0 +1,26 @@
1+# collaborators add lets a user push main, which only the owner could;2+# rm takes it back, and they push branches under their own name again,3+# like anyone else.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user fox7+repo dami/greet8+clone dami dami/greet "$T/w/dami"9+at "$T/w/dami" commit "first"10+ok push_as dami "$T/w/dami" origin main11+clone fox dami/greet "$T/w/fox"12+13+at "$T/w/fox" commit "from fox"14+fails_with "only dami can push main" push_as fox "$T/w/fox" origin main15+ok ssh_as dami collaborators dami/greet add fox16+has "$T_LOG" '^fox collaborates on dami/greet: they can push anything to it'17+eq "$(cat "$FORGE_ROOT/dami/greet.git/forge/collaborators")" fox18+ok push_as fox "$T/w/fox" origin main19+eq "$(git --git-dir="$FORGE_ROOT/dami/greet.git" log -1 --format=%s main)" "from fox"20+21+ok ssh_as dami collaborators dami/greet rm fox22+has "$T_LOG" '^fox no longer collaborates on dami/greet'23+eq "$(cat "$FORGE_ROOT/dami/greet.git/forge/collaborators")" ""24+at "$T/w/fox" commit "again"25+fails_with "only dami can push main" push_as fox "$T/w/fox" origin main26+ok push_as fox "$T/w/fox" origin HEAD:fox/again@@ -0,0 +1,15 @@
1+# who of a private repo you can't read gets what who of a missing repo2+# gets, byte for byte; once you read it, you see who else does.3+. "$(dirname "$0")/../lib.sh"4+user dami5+user eve6+repo --private dami/secret7+ssh_as eve who dami/secret >"$T/private" 2>&1 && fail "eve sees who reads dami/secret"8+aside dami/secret9+ssh_as eve who dami/secret >"$T/missing" 2>&1 && fail "who of a missing repo succeeded"10+back dami/secret11+cmp "$T/private" "$T/missing" || fail "who tells a private repo from a missing one: $(cat "$T/private") / $(cat "$T/missing")"12+eq "$(cat "$T/private")" "forge: no repo dami/secret"13+ok ssh_as dami readers dami/secret add eve14+ok ssh_as eve who dami/secret15+has "$T_LOG" '^reader +eve$'