Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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