Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: ShellCheck scripts
run: shellcheck install.sh tools/gen-formula.sh
run: shellcheck install.sh tools/gen-formula.sh tools/fleet/co-own.sh

- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ func syncOpResult(t *testing.T, rec cli.SyncRecord, id string) (int, bool) {

// checkDirSnapshot records every file under dir by content, so a test can
// prove a command wrote nothing at all.
// checkDirSnapshot maps every working-tree file under dir to its content.
// .git is excluded deliberately: the tool's one writer path writes CODEOWNERS
// files, never repository internals — but git itself rewrites .git bookkeeping
// behind the fixture's own commands (git 2.55 detaches maintenance after
// commit), and those writes landing between two snapshots made "writes
// nothing" tests fail on whichever one the race hit (R-33 skip/dry-run,
// R-35b/R-35d on the 2.55 CI runners). Hashing .git asserts git's internals
// are byte-stable across a run, which was never this tool's contract.
func checkDirSnapshot(t *testing.T, dir string) map[string]string {
t.Helper()
snap := map[string]string{}
Expand All @@ -72,6 +80,9 @@ func checkDirSnapshot(t *testing.T, dir string) map[string]string {
return err
}
if d.IsDir() {
if d.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
b, err := os.ReadFile(p)
Expand Down
74 changes: 74 additions & 0 deletions tools/fleet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# co-own.sh — undo an exclusive fleet-wide CODEOWNERS line

Turns "`@org/platform` owns `.github/workflows` alone" into "`@org/platform`
owns it together with whoever owns the surrounding tree" — in one repo, so a
fleet loop can run it across a hundred.

No single policy file can express this end state: the co-owners differ per
repo. The script derives them per repo and proves the result with
`codeowners-tool` before reporting success.

## Usage

```sh
tools/fleet/co-own.sh --repo work/org/name --scope .github/workflows --owner @org/platform
```

`--branch NAME` overrides the work branch (default `co-own/<scope>`);
`CODEOWNERS_TOOL` points at the binary if it is not on `PATH`.

## What it does, per repo

1. If the scope is already co-owned, matches no files, or the owner does not
own it at all: touch nothing, exit 0.
2. If a dedicated `SCOPE OWNER` line exists, delete it — when the broader rule
behind it already lists the owner, that alone is the answer.
3. When the broader rule lacks the owner, deleting would revoke instead of
share; the script inverts: keeps the line and adds the broader team(s) to it.
4. Either way the edit lands as **one commit on a new branch**, and is kept
only if the tool proves nothing outside the scope changed owners
(`verify --scope`) and every in-scope path ends with the owner **plus at
least one other team**.
5. Anything unprovable — nothing behind the line, exclusivity from a broad
rule, a dirty clone — is refused with the repo handed back untouched.

## Exit codes and output

Same contract as `sync`: `0` converged (or nothing to do), `2` this repo needs
a human, `3` the invocation is broken and would fail identically everywhere.
Stdout is one JSON object: `{status, detail, branch, codeowners_path}` with
status `shared | unchanged | skipped | needs-human`.

## A fleet loop

Cloning, auth and PRs stay with `gh`, as in [docs/FLEET.md](../../docs/FLEET.md):

```sh
while read -r repo; do
gh repo clone "$repo" "work/$repo" -- --depth 1 -q || { echo "$repo" >> clone-failed; continue; }
code=0
tools/fleet/co-own.sh --repo "work/$repo" \
--scope .github/workflows --owner @org/platform >> results.jsonl || code=$?
case $code in
0) if [ "$(tail -1 results.jsonl | jq -r .status)" = shared ]; then
git -C "work/$repo" push -u origin HEAD
gh pr create --repo "$repo" --title 'chore: co-own .github/workflows' --fill
fi ;;
2) echo "$repo" >> needs-human ;;
*) exit "$code" ;; # broken invocation — stop the run
esac
done < repos.txt
```

`needs-human` repos are the ones where somebody must decide who co-owns the
scope; `jq -r 'select(.status=="needs-human") | .detail' results.jsonl` says
why for each.

## Tests

`coown_test.go` is the specification — one e2e test per behavior above,
running the real binary against real git repositories:

```sh
go test ./tools/fleet/
```
154 changes: 154 additions & 0 deletions tools/fleet/co-own.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# co-own.sh — make OWNER a shared, never exclusive, owner of SCOPE in one repo.
#
# For the rollout shape where a fleet-wide line like
# .github/workflows @org/platform
# should become "platform still owns this, together with whoever owns the
# surrounding tree". The co-owners differ per repo, so no single policy can
# state the end state; this script derives it per repo and proves it with
# codeowners-tool before reporting success. See README.md for the fleet loop.
#
# Usage: co-own.sh --repo DIR --scope PATTERN --owner @org/team [--branch NAME]
# Env: CODEOWNERS_TOOL — path to the binary (default: codeowners-tool on PATH)
#
# Exit codes follow the tool's fleet contract:
# 0 converged (edited, already correct, or nothing in scope)
# 2 this repo needs a human; handed back untouched
# 3 bad invocation or broken environment — will fail identically everywhere
#
# Stdout is exactly one JSON object: {status, detail, branch, codeowners_path}.
# status: shared | unchanged | skipped | needs-human. Everything else -> stderr.
set -euo pipefail

REPO="" SCOPE="" OWNER="" BRANCH=""
while [ $# -gt 0 ]; do
case "$1" in
--repo) REPO="${2:?}"; shift 2 ;;
--scope) SCOPE="${2:?}"; shift 2 ;;
--owner) OWNER="${2:?}"; shift 2 ;;
--branch) BRANCH="${2:?}"; shift 2 ;;
*) echo "co-own.sh: unknown argument: $1" >&2; exit 3 ;;
esac
done
if [ -z "$REPO" ] || [ -z "$SCOPE" ] || [ -z "$OWNER" ]; then
echo "usage: co-own.sh --repo DIR --scope PATTERN --owner @org/team [--branch NAME]" >&2
exit 3
fi

TOOL="${CODEOWNERS_TOOL:-codeowners-tool}"
for bin in git jq awk; do
command -v "$bin" > /dev/null || { echo "co-own.sh: $bin not found" >&2; exit 3; }
done
command -v "$TOOL" > /dev/null || { echo "co-own.sh: codeowners-tool not found (set CODEOWNERS_TOOL)" >&2; exit 3; }

# Scope is compared against snapshot paths with slashes normalized away.
S="${SCOPE#/}"; S="${S%/}"
CFILE=""
Comment on lines +44 to +46

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

emit() { # status detail
jq -cn --arg s "$1" --arg d "$2" --arg b "${BRANCH}" --arg f "${CFILE}" \
'{status: $s, detail: $d, branch: $b, codeowners_path: $f}'
}
skip() { emit "${2:-skipped}" "$1"; exit 0; }
human() { emit needs-human "$1"; exit 2; }

git -C "$REPO" rev-parse --git-dir > /dev/null 2>&1 || human "not a git repository"
[ -z "$(git -C "$REPO" status --porcelain)" ] || human "working tree not clean"

"$TOOL" snapshot --repo "$REPO" --out "$TMP/before.json" >&2 \
|| human "snapshot failed (no commits?)"
CFILE="$(jq -r '.codeowners_path // empty' "$TMP/before.json")"
[ -n "$CFILE" ] || skip "no CODEOWNERS file"

# state SNAPSHOT -> none|no-owner|shared|exclusive, over the in-scope paths.
state() {
jq -r --arg s "$S" --arg o "$OWNER" '
[.ownership | to_entries[] | select(.key == $s or (.key | startswith($s + "/")))
| .value // []] as $sets
| if ($sets | length) == 0 then "none"
elif [$sets[] | index($o)] | any(. == null) then "no-owner"
elif [$sets[] | length >= 2] | all then "shared"
else "exclusive" end' "$1"
}
case "$(state "$TMP/before.json")" in
none) skip "no tracked files in scope" ;;
no-owner) skip "$OWNER does not own every path in scope; granting is a different decision" ;;
shared) skip "already co-owned" unchanged ;;
esac

# Exclusivity must come from a dedicated "SCOPE OWNER" line; anything else
# (a broad sole-owner rule, extra owners, an inline comment) has no move that
# provably stays inside the scope, so it goes to a human instead.
if ! awk -v s="$S" -v o="$OWNER" '
{ line = $0; sub(/\r$/, "", line)
n = split(line, f, /[ \t]+/); i = 1
if (n > 0 && f[1] == "") i = 2
p = f[i]; sub(/^\/+/, "", p); sub(/\/+$/, "", p)
if (n - i + 1 == 2 && p == s && f[i+1] == o) { deleted = 1; next }
print }
END { exit deleted ? 0 : 1 }
' "$REPO/$CFILE" > "$TMP/without-line"; then
human "no dedicated \"$SCOPE $OWNER\" line; exclusivity comes from elsewhere in $CFILE"
fi

BASE="$(git -C "$REPO" rev-parse HEAD)"
ORIG_REF="$(git -C "$REPO" symbolic-ref -q --short HEAD || echo "$BASE")"
if [ -z "$BRANCH" ]; then
BRANCH="co-own/$(printf '%s' "$S" | sed -e 's#[^A-Za-z0-9_-]#-#g' -e 's#^[.-]*##')"
fi
git -C "$REPO" rev-parse -q --verify "refs/heads/$BRANCH" > /dev/null \
&& human "branch $BRANCH already exists"

restore_and_human() { # detail
git -C "$REPO" reset -q --hard "$BASE"
git -C "$REPO" checkout -q "$ORIG_REF"
git -C "$REPO" branch -qD "$BRANCH" 2> /dev/null || true
human "$1"
}

git -C "$REPO" checkout -qb "$BRANCH"

# Try the clean answer first: delete the line and let the broader rule take
# over. Snapshot resolves the committed tree, so the probe needs a commit;
# everything squashes into one reviewable commit at the end.
cat "$TMP/without-line" > "$REPO/$CFILE"
git -C "$REPO" commit -qam "probe: line removed"
"$TOOL" snapshot --repo "$REPO" --out "$TMP/fallback.json" >&2 \
|| restore_and_human "snapshot failed after line removal"

if [ "$(state "$TMP/fallback.json")" != shared ]; then
# The broader rule drops or loses $OWNER, so deleting alone breaks "stays an
# owner". Invert: keep the line, add the broader team(s) to it — one
# add_owner with an owner list is one line change (R-33b).
FALLBACK="$(jq -r --arg s "$S" --arg o "$OWNER" '
[.ownership | to_entries[] | select(.key == $s or (.key | startswith($s + "/")))
| .value // []] | unique as $sets
| if ($sets | length) != 1 then ""
else [$sets[0][] | select(. != $o)] | join(", ") end' "$TMP/fallback.json")"
[ -n "$FALLBACK" ] || restore_and_human "nothing co-ownable behind the line; a human decides who shares $SCOPE"
git -C "$REPO" reset -q --hard "$BASE"
RC=0
"$TOOL" sync --repo "$REPO" --op "add_owner($S, [$FALLBACK])" >&2 || RC=$?
case "$RC" in
0) git -C "$REPO" commit -qam "probe: line amended" ;;
2) restore_and_human "tool refused to amend: add_owner($S, [$FALLBACK])" ;;
*) restore_and_human "tool exited $RC on add_owner($S, [$FALLBACK])" ;;
esac
fi

git -C "$REPO" reset -q --soft "$BASE"
git -C "$REPO" commit -qm "chore: co-own $SCOPE ($OWNER no longer exclusive)"

# Prove the result before reporting it: nothing outside the scope moved, and
# every in-scope path is owned by $OWNER plus at least one other team.
"$TOOL" snapshot --repo "$REPO" --out "$TMP/after.json" >&2 \
|| restore_and_human "snapshot failed after edit"
"$TOOL" verify --before "$TMP/before.json" --after "$TMP/after.json" --scope "$S" >&2 \
|| restore_and_human "ownership moved outside $SCOPE; edit discarded"
[ "$(state "$TMP/after.json")" = shared ] \
|| restore_and_human "end state is not co-owned; edit discarded"

emit shared "one commit on $BRANCH, ready to push"
Loading
Loading