Base CI rollout tooling: markdownlint bends, add-ci --upgrade, md-same-text, rollout-ci (#49)

* Base CI: markdownlint bends for the house style, with reasons (base-ci-T001)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* Base CI: exclusion tests prove the shared bends still load (base-ci-T001 fix)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* add-ci.sh: --upgrade replaces the known gitleaks-only config (base-ci-T002)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* md-same-text.sh: prove a Markdown change is formatting only (base-ci-T003)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* md-same-text.sh: compare link targets and indented code (base-ci-T003 fix)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* rollout-ci.sh: push and protect the base CI on existing repos (base-ci-T004)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* rollout-ci.sh: lease on the last push, leave merged repos alone (base-ci-T004 fix)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* md-same-text: an autolink is not a link change (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* md-same-text: raw HTML is part of the compared text (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* md-same-text: an added markdownlint-disable comment differs (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* rollout-ci.sh: accept .github as a repo name (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* rollout-ci.sh: a global insteadOf can't route around the token (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* rollout-ci.sh: push in another org needs ROLLOUT_BOT (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Base CI: the shell piece ships .shellcheckrc with external-sources (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Base CI README: the rollout branch passes gitleaks-history too (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* lint-config test: a comment no longer reads as a shellcheck directive (base-ci tooling review)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Bruno Guilhermo de Barros Valério <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-18 22:47:19 +02:00
1 parent 034d2980dd
commit d27a0a02ec
14 files changed
+830 -6

No files matched your search

+3 -1
View File
@@ -43,8 +43,10 @@ folder.
| Scan git history for secrets | `scripts/scan-history.sh` |
| Create a repo with its first commit (`README.md`, licences, base CI) and its protection | `scripts/new-repo.sh --org <org> <repo> "<description>"`. Founder only |
| Write the licence files or the base CI into a folder | `scripts/add-licences.sh`, `scripts/add-ci.sh`. Founder only |
| Push the prepared `base-ci` branches of existing repos and open their PRs, then require `lint` after the merge | `scripts/rollout-ci.sh --org <org> push\|protect <repo>...`. Founder only |
| Prove that a Markdown change is formatting only, such as `markdownlint-cli2 --fix` output | `scripts/md-same-text.sh <base-ref> <head-ref>` |
| Scaffold a new Go component | `scripts/scaffold-component.sh` |
| Render and check diagrams | `scripts/diagrams.sh` |
Only the founder runs `scripts/new-repo.sh`, `scripts/create-repo.sh`, `scripts/add-licences.sh`, `scripts/add-ci.sh`,
`scripts/setup-repo.sh` and `scripts/create-pr-bot.sh`.
`scripts/setup-repo.sh`, `scripts/rollout-ci.sh` and `scripts/create-pr-bot.sh`.
+20
View File
@@ -211,6 +211,26 @@ Run one check with `--check <name>`, or name repos to check only those.
repo has. The org's Actions allowlist must hold both of `lint.yml`'s actions, `actions/checkout`
and `actions/setup-python`, at the SHAs it pins, or `lint` never starts; a pin bump updates the
allowlist too.
- **Existing repos get the same base CI.** An agent prepares each repo's `base-ci` branch in a fresh
clone under `rollout/`, beside the `ouroboric` checkout. `add-ci.sh --upgrade` replaces the old
gitleaks-only config, and formatting-only fixes make the hooks pass;
[`scripts/md-same-text.sh`](../../scripts/md-same-text.sh) proves each Markdown fix changes no
text. Then the founder runs
[`scripts/rollout-ci.sh --org <org> push <repo>…`](../../scripts/rollout-ci.sh). It pushes the
branch, because the pr-bot can't push workflows, and opens the PR as the pr-bot. Running it again
after a rebase updates the branch and reuses the PR. If `base-ci` moved on GitHub since the last
push, it refuses that repo and prints both SHAs: the agent folds the move in and acknowledges it
with `git update-ref refs/rollout-ci/base-ci <GitHub's SHA>`, then the founder runs push again.
After the merge, push does nothing, and the founder runs
`rollout-ci.sh --org <org> protect <repo>…`, which runs `setup-repo.sh`, so `lint` becomes
required. It skips and names any repo whose `main` doesn't hold `lint.yml` yet. Both modes are
safe to run twice and need the same token as `new-repo.sh`.
- **A lint config bends only for a reason.** When a linter and the house style disagree, ask
whether the house style gets real value from its own way. If yes, the linter bends: the shared
config gets a setting, or a repo excludes files that aren't docs or config, such as test inputs,
in its own file, with a comment that gives the reason. If no, the files change to the linter's
default. Nothing is excluded from gitleaks or reuse.
[The bends and their reasons](../../scripts/templates/ci/README.md).
- **Agents never create repos or change their settings.** They reach GitHub only as
`ouroboric-pr-bot`, through [`scripts/as-bot.sh`](../../scripts/as-bot.sh), which can't; run the
checks with `scripts/as-bot.sh --read all scripts/check-repos.sh`.
+11 -3
View File
@@ -6,7 +6,9 @@
# If any target exists, a folder it goes in is not a folder, or any piece breaks the format, it exits 2
# and writes nothing. A piece without a final newline gets one.
# Run by new-repo.sh; the founder can also run it on any existing folder.
# add-ci.sh (--org <name>|--config <path>) [--ci <piece>,...] (--check | <dir>)
# add-ci.sh (--org <name>|--config <path>) [--ci <piece>,...] [--upgrade] (--check | <dir>)
# --upgrade lets an existing .pre-commit-config.yaml be replaced, but only if it is byte for byte the old
# gitleaks-only scaffold (templates/ci/upgrade/gitleaks-only.yaml); any other target must still not exist.
# --check validates the pieces and writes nothing. ADD_CI_PIECES replaces the pieces folder.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -14,15 +16,16 @@ die() { echo "add-ci: $*" >&2; exit 2; }
# shellcheck source=repo-config.sh
. "$HERE/repo-config.sh"
usage='usage: add-ci.sh (--org <name>|--config <path>) [--ci <piece>,...] (--check | <dir>)'
usage='usage: add-ci.sh (--org <name>|--config <path>) [--ci <piece>,...] [--upgrade] (--check | <dir>)'
repo_config_args "$@"
repo_config_load
ci='' have_ci='' check='' dir=''
ci='' have_ci='' check='' upgrade='' dir=''
set -- "${RC_REST[@]}"
while [ $# -gt 0 ]; do
case "$1" in
--ci) [ $# -ge 2 ] && [ -n "$2" ] || die "--ci needs a value"; ci="$2"; have_ci=1; shift ;;
--check) check=1 ;;
--upgrade) upgrade=1 ;;
-*) die "$usage" ;;
*) [ -z "$dir" ] || die "$usage"; dir="$1" ;;
esac
@@ -81,7 +84,12 @@ lint="$HERE/templates/ci/lint.yml"
# Check every target, and every folder it goes in, before writing any.
targets=(.pre-commit-config.yaml .github/workflows/lint.yml "${extra[@]+"${extra[@]}"}")
known="$HERE/templates/ci/upgrade/gitleaks-only.yaml"
for f in "${targets[@]}"; do
if [ -n "$upgrade" ] && [ "$f" = .pre-commit-config.yaml ] && [ -f "$dir/$f" ] && [ ! -L "$dir/$f" ]; then
cmp -s -- "$known" "$dir/$f" || die "$dir/$f is not the known gitleaks-only file; nothing was written"
continue
fi
[ ! -e "$dir/$f" ] && [ ! -L "$dir/$f" ] || die "$dir/$f exists; nothing was written"
p="$(dirname "$f")"
while [ "$p" != . ]; do
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# Compare two versions of one Markdown file: print "same", "lintoff", "code", "links" or "text". Used by
# md-same-text.sh.
# md-same-text.py OLD NEW
# Checked in this order, and the first that differs is printed:
# "lintoff": NEW has more markdownlint-disable (-next-line, -line) or markdownlint-configure-file
# comments than OLD. Turning a rule off is not a formatting fix.
# "code": the contents of fenced and indented code blocks differ by even one byte.
# "links": the link and image targets differ (href of links, src of images, href= and src= in raw HTML).
# An autolink (<https://...>) is not counted: its target is its text.
# "text": the rendered text differs (tags stripped, entities decoded, whitespace collapsed). Raw HTML
# stays in the text as its source, so `<issue>` and <issue> are the same text.
# Run it as `python3 -I`, so nothing is imported from the current folder; md-same-text.sh does that
# through `uvx --with markdown-it-py==3.0.0`. It only reads the two paths.
import html
import re
import sys
from markdown_it import MarkdownIt
def walk(tokens):
for t in tokens:
yield t
yield from walk(t.children or [])
def parts(path):
with open(path, encoding='utf-8', newline='') as f:
src = f.read()
md = MarkdownIt('commonmark').enable('table')
# Raw HTML is rendered escaped, so stripping the tags keeps its source as text.
for rule in ('html_inline', 'html_block'):
md.add_render_rule(rule, lambda self, tokens, idx, options, env: html.escape(tokens[idx].content))
text = html.unescape(re.sub(r'<[^>]*>', '', md.render(src)))
toks = list(walk(md.parse(src)))
code = [t.content for t in toks if t.type in ('fence', 'code_block')]
links = []
for t in toks:
# An autolink (<https://...>) shows its target as its text, which the text check compares.
if t.type == 'link_open' and t.markup != 'autolink':
links.append(t.attrGet('href'))
elif t.type == 'image':
links.append(t.attrGet('src'))
elif t.type in ('html_block', 'html_inline'):
links += re.findall(r'\b(?:href|src)\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)', t.content)
lintoff = len(re.findall(r'<!--\s*markdownlint-(?:disable|configure-file)', src))
return ' '.join(text.split()), code, links, lintoff
if len(sys.argv) != 3:
sys.exit('usage: md-same-text.py OLD NEW')
old, new = parts(sys.argv[1]), parts(sys.argv[2])
if new[3] > old[3]:
print('lintoff')
else:
print(next((n for n, i in (('code', 1), ('links', 2), ('text', 0)) if old[i] != new[i]), 'same'))
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Prove that a Markdown change is formatting only. An agent runs it after `markdownlint-cli2 --fix`.
# md-same-text.sh <base-ref> <head-ref> run inside a git repo; it reads only, and needs no token
# For each .md file changed between the refs it prints `same <file>` or `DIFFERS <file>: <why>`:
# lintoff a markdownlint-disable or markdownlint-configure-file comment was added
# text the rendered text differs (whitespace and Markdown markup are ignored; raw HTML counts as text)
# code a code block (fenced or indented) differs by any byte, even a tab or a trailing space
# links a link or image target differs (including href= and src= in raw HTML)
# added / deleted the file exists in only one ref
# Exit 0 if every file is the same or no .md file changed, 1 if any differs, 2 on bad input.
# The renderer is md-same-text.py, with markdown-it-py pinned, run by uvx as `python3 -I`.
set -euo pipefail
die() { echo "md-same-text.sh: $*" >&2; exit 2; }
[ $# -eq 2 ] || die "usage: md-same-text.sh <base-ref> <head-ref>"
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
for r in "$1" "$2"; do git rev-parse --verify --quiet "$r^{commit}" >/dev/null || die "bad ref: $r"; done
PY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/md-same-text.py"
T="$(mktemp -d)"
trap 'rm -rf -- "$T"' EXIT
fail=0
diff_names() { git diff -z --no-renames --name-only --diff-filter="$1" "$2" "$3" -- '*.md'; }
while IFS= read -r -d '' f; do echo "DIFFERS $f: added"; fail=1; done < <(diff_names A "$1" "$2")
while IFS= read -r -d '' f; do echo "DIFFERS $f: deleted"; fail=1; done < <(diff_names D "$1" "$2")
while IFS= read -r -d '' f; do
git show "$1:$f" >"$T/old.md"
git show "$2:$f" >"$T/new.md"
v="$(uvx -q --with 'markdown-it-py==3.0.0' python3 -I "$PY" "$T/old.md" "$T/new.md")"
if [ "$v" = same ]; then echo "same $f"; else echo "DIFFERS $f: $v"; fail=1; fi
done < <(diff_names M "$1" "$2")
exit $fail
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# shellcheck source-path=SCRIPTDIR
# Roll the base CI out to existing repos in the org that a config names, in two steps. An agent has
# prepared each repo's base-ci branch in a clone at $ROLLOUT_ROOT/<repo> (default: rollout/ beside the
# ouroboric checkout), with add-ci.sh --upgrade and the formatting fixes.
# push: pushes each local base-ci branch to GitHub, then opens its pull request as the pr-bot
# (as-bot.sh), or prints the one already open. The founder pushes because the pr-bot can't
# push .github/workflows/. Each push is recorded in the clone's refs/rollout-ci/base-ci. A
# second run after a rebase updates the branch, with --force-with-lease on that record. If
# base-ci moved on GitHub since the last push (a person's commit, "Update branch"), push
# refuses that repo and prints both SHAs: fold the move into the local branch, then
# acknowledge it with git update-ref refs/rollout-ci/base-ci <GitHub's SHA>, and run push
# again. A repo whose main already holds lint.yml (the PR merged) is left alone. A repo
# without a local base-ci branch is skipped and named.
# protect: after the merge, runs setup-repo.sh on each repo whose main holds
# .github/workflows/lint.yml, so lint becomes required, then (for ouroboric-dev)
# check-repos.sh --check ruleset. A repo whose main has no lint.yml yet is named and left
# alone, so lint is never required early. A repo that doesn't exist fails.
# Both are safe to run twice. Exit 0 when every repo succeeded or is not ready yet, 1 when any repo was
# skipped or failed, 2 for usage. It never prompts.
# Founder-run: GH_TOKEN must hold the same short-lived admin token as new-repo.sh (Contents and
# Workflows write for push, Administration for protect). Git gets it only through the credential
# helper; it is never printed. The pr-bot's key must be in place for as-bot.sh. as-bot.sh serves
# ouroboric-dev only: push in another org exits 2, before any repo is touched, unless ROLLOUT_BOT names
# a script that runs a command as that org's bot (as-bot.sh's arguments: <repo> <command>...).
# GH_TOKEN=... rollout-ci.sh (--org <name>|--config <path>) push|protect <repo>...
set -euo pipefail
exec </dev/null
export GIT_TERMINAL_PROMPT=0
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ROLLOUT_ROOT:-$(cd "$HERE/../.." && pwd)/rollout}"
GIT="${ROLLOUT_GIT:-https://github.com}"
BOT="${ROLLOUT_BOT:-$HERE/as-bot.sh}"
SETUP="${ROLLOUT_SETUP:-$HERE/setup-repo.sh}"
CHECK="${ROLLOUT_CHECK:-$HERE/check-repos.sh}"
PROPOSAL="https://github.com/ouroboric-dev/yggdrasil/blob/main/docs/2-engineering/changes/2026-10-18-base-ci-rollout/proposal.md"
die() { echo "rollout-ci: $*" >&2; exit 2; }
# shellcheck source=repo-config.sh
. "$HERE/repo-config.sh"
USAGE='usage: rollout-ci.sh (--org <name>|--config <path>) push|protect <repo>...'
[ -n "${GH_TOKEN:-}" ] || die "GH_TOKEN must hold the short-lived admin token"
repo_config_args "$@"
repo_config_load
set -- "${RC_REST[@]}"
[ $# -ge 2 ] || die "$USAGE"
mode="$1"
shift
case "$mode" in push | protect) ;; *) die "unknown mode: $mode; $USAGE" ;; esac
for r in "$@"; do
# GitHub names: .github is one; . and .. are paths, and a leading - would read as an option.
[[ "$r" =~ ^[A-Za-z0-9._-]+$ && "$r" != -* && "$r" != . && "$r" != .. ]] || die "bad repo name: $r"
done
if [ "$mode" = push ] && [ "$RC_ORG" != ouroboric-dev ] && [ -z "${ROLLOUT_BOT:-}" ]; then
die "as-bot.sh serves ouroboric-dev only; set ROLLOUT_BOT for $RC_ORG"
fi
# The helper reads GH_TOKEN when git asks, so the token never appears in a command line. Git applies
# the longest matching insteadOf, so the org's own rule beats a global one that sends
# https://github.com/ to SSH, around the token (as in as-bot.sh).
# shellcheck disable=SC2016 # $1 and $GH_TOKEN expand in the helper, not here
cred_git() {
git -c credential.helper= \
-c 'credential.helper=!f() { test "$1" = get && printf "username=x-access-token\npassword=%s\n" "$GH_TOKEN"; }; f' \
-c "url.$GIT/$RC_ORG/.insteadOf=$GIT/$RC_ORG/" \
"$@"
}
# lookup PATH: GETs PATH with the founder's token. Returns 0 if it exists, 1 on a 404, and 2 on any
# other failure, with the error in $err.
err=''
lookup() {
err="$(env -u GITHUB_TOKEN gh api "$1" --silent 2>&1 >/dev/null)" && return 0
[[ "$err" == *"HTTP 404"* ]] && return 1
return 2
}
# Only push writes refs/rollout-ci/base-ci: the SHA it last pushed, or no ref before the first push.
# It is the lease, so a move on GitHub since then is refused, not overwritten. (refs/remotes/ won't do:
# git fetch moves those.)
LAST=refs/rollout-ci/base-ci
push_one() {
local r="$1" w="$ROOT/$1" remote="$GIT/$RC_ORG/$1.git" have want last fix url s=0
if [ ! -e "$w/.git" ] || ! have="$(git -C "$w" rev-parse -q --verify refs/heads/base-ci)"; then
echo "rollout-ci: $r: no base-ci branch in $w; skipped" >&2
return 1
fi
# After the merge, base-ci is deleted on GitHub; pushing it again would open a second PR.
lookup "repos/$RC_ORG/$r/contents/.github/workflows/lint.yml" || s=$?
case "$s" in
0) echo "rollout-ci: $r: lint.yml is already on main; run protect"; return 0 ;;
2) echo "rollout-ci: $r: can't tell whether main holds lint.yml, so nothing is pushed: $err" >&2; return 1 ;;
esac
if ! want="$(cred_git -C "$w" ls-remote "$remote" refs/heads/base-ci)"; then
echo "rollout-ci: $r: can't read base-ci from $remote" >&2
return 1
fi
want="${want%%[[:space:]]*}"
last="$(git -C "$w" rev-parse -q --verify "$LAST")" || last=''
if [ "$want" = "$have" ]; then
git -C "$w" update-ref "$LAST" "$have" || { echo "rollout-ci: $r: recording the last push failed" >&2; return 1; }
echo "rollout-ci: $r: up to date"
elif [ "$want" != "$last" ]; then
if [ -n "$want" ]; then fix="git update-ref $LAST $want"; else fix="git update-ref -d $LAST"; fi
echo "rollout-ci: $r: the remote base-ci moved: GitHub has ${want:-none}, the last push was ${last:-none}; fold it in, then $fix" >&2
return 1
# The lease is the last push, so a move racing this run is refused too. Empty means "must not exist".
elif ! cred_git -C "$w" push -q --force-with-lease="refs/heads/base-ci:$last" "$remote" \
refs/heads/base-ci:refs/heads/base-ci >&2; then
echo "rollout-ci: $r: the push failed; if someone else moved base-ci on GitHub, fold it in and run push again" >&2
return 1
elif ! git -C "$w" update-ref "$LAST" "$have"; then
echo "rollout-ci: $r: base-ci is pushed, but recording it failed; run git update-ref $LAST $have" >&2
return 1
fi
if ! url="$("$BOT" "$r" gh pr list --repo "$RC_ORG/$r" --head base-ci --state open --json url -q '.[0].url')"; then
echo "rollout-ci: $r: can't list its pull requests" >&2
return 1
fi
if [ -z "$url" ] || [ "$url" = null ]; then
if ! url="$("$BOT" "$r" gh pr create --repo "$RC_ORG/$r" --base main --head base-ci \
--title "Base CI: lint required" \
--body "This adds the base CI that new repos get, a full .pre-commit-config.yaml and .github/workflows/lint.yml, with the formatting fixes that make lint pass, as the base-CI rollout proposal says: $PROPOSAL. The founder pushed this branch, because the pr-bot can't push workflows. After this merges, the founder runs rollout-ci.sh protect $r, so lint becomes a required check.")"; then
echo "rollout-ci: $r: base-ci is pushed, but opening the pull request failed; run push again" >&2
return 1
fi
fi
echo "rollout-ci: $r: $url"
}
protect_one() {
local r="$1" s=0
lookup "repos/$RC_ORG/$r" || s=$?
case "$s" in
1) echo "rollout-ci: $r: no such repo in $RC_ORG" >&2; return 1 ;;
2) echo "rollout-ci: $r: can't read the repo, so it is not protected: $err" >&2; return 1 ;;
esac
s=0
lookup "repos/$RC_ORG/$r/contents/.github/workflows/lint.yml" || s=$?
if [ "$s" = 0 ]; then
if ! "$SETUP" "${RC_FROM[@]}" "$r"; then
echo "rollout-ci: $r: setup-repo.sh failed" >&2
return 1
fi
[ "$RC_ORG" = ouroboric-dev ] || return 0
"$CHECK" --check ruleset "$r"
elif [ "$s" = 1 ]; then
echo "rollout-ci: $r: lint.yml is not on main yet; not protected"
else
echo "rollout-ci: $r: can't tell whether main holds lint.yml, so it is not protected: $err" >&2
return 1
fi
}
rc=0
for r in "$@"; do
if [ "$mode" = push ]; then push_one "$r" || rc=1; else protect_one "$r" || rc=1; fi
done
exit "$rc"
+37 -2
View File
@@ -10,8 +10,8 @@ Every hook revision is pinned to a release tag.
| --- | --- | --- |
| `gitleaks` | gitleaks, plus `gitleaks-history` (manual stage, the whole history) | |
| `reuse` | reuse-tool, `reuse lint` | |
| `markdown` | markdownlint-cli2 (the config lets front matter `title:` and an H1 coexist) | `.markdownlint.yaml` |
| `shell` | shellcheck-py | |
| `markdown` | markdownlint-cli2. The config bends four rules: MD025 lets front matter `title:` and an H1 coexist; MD010 ignores code blocks, because gofmt requires tabs and the plan tools copy plan code verbatim; MD033 allows `<picture>`, `<source>` and `<img>`, because the docs standard prescribes `<picture>` for themed images and the org profile opens with `<img>`; MD041 is off, because journal entries, PR templates, Claude skill files and the org profile banner open without an H1 on purpose, and the docs standard's front matter rule already checks every other doc's title | `.markdownlint.yaml` |
| `shell` | shellcheck-py. The config sets `external-sources=true`, because the hook passes no `-x`: without it, SC1091 fires on every sourced file that isn't among the hook's inputs, even with `source-path` directives. It makes shellcheck follow and check sourced files, which is stricter | `.shellcheckrc` |
| `actions` | actionlint | |
| `yaml` | yamllint | `.yamllint.yaml` |
@@ -41,6 +41,41 @@ Two pieces must not ship the same path.
Realms will ship their own pieces in this format later (the repo-bootstrap proposal, future work item 3).
## Existing repos
`add-ci.sh --upgrade` also works on a repo whose `.pre-commit-config.yaml` is the old gitleaks-only scaffold
(`upgrade/gitleaks-only.yaml`). It replaces that file, and adds `lint.yml` and the piece files. If the config
differs by even one byte, it exits 2 and writes nothing; every other target must still not exist.
The rollout to an existing repo, one `base-ci` branch per repo:
1. An agent clones the repo fresh into `rollout/<repo>`, beside the `ouroboric` checkout, and cuts `base-ci`
from `origin/main`. It runs `add-ci.sh --upgrade`, then fixes what the hooks flag, formatting only, until
both `pre-commit run --all-files` and `pre-commit run --hook-stage manual gitleaks-history --all-files` (the
whole history) pass. `md-same-text.sh origin/main HEAD` proves each Markdown fix keeps the text, the code
blocks and the links, and turns no rule off with a `markdownlint-disable` comment.
2. The founder runs `GH_TOKEN=... rollout-ci.sh --org <org> push <repo>...`. It pushes `base-ci`, because the
pr-bot can't push `.github/workflows/`, and opens the PR as the pr-bot, or prints the one already open. It
records each push in the clone's `refs/rollout-ci/base-ci`. After a rebase, the same command updates the
branch, with `--force-with-lease` on that record. If `base-ci` moved on GitHub since the last push (a
person's commit, a review suggestion, "Update branch"), push refuses that repo and prints both SHAs. The
agent folds the move into the local branch and acknowledges it with
`git update-ref refs/rollout-ci/base-ci <GitHub's SHA>`; then the founder runs push again. Once `main` holds
`lint.yml`, push leaves the repo alone and says to run protect.
3. After the merge, the founder runs `rollout-ci.sh --org <org> protect <repo>...`. It runs `setup-repo.sh`, so
`lint` becomes required, only on a repo whose `main` holds `lint.yml`, and names the others.
A repo that needs an exclusion keeps it in its own file, never in the shared copies, with a comment that gives
the reason. Only files that aren't docs or config people read are excluded, such as test inputs read by code,
or the pieces, which are fragments rather than YAML documents. Nothing is excluded from gitleaks or reuse.
- markdownlint: `.markdownlint-cli2.yaml` holding only `ignores:`. The rules still come from
`.markdownlint.yaml`.
- yamllint: `.yamllint`, holding `extends: .yamllint.yaml` and `ignore:`. yamllint reads it before
`.yamllint.yaml`.
`scripts/tests/lint-config.test.sh` proves that both keep the shared rules.
## Bumping a pin
Change the `rev` in a reviewed pull request. The release must be at least two weeks old. For `lint.yml`, the
@@ -1,6 +1,15 @@
default: true
# gofmt requires tabs, and the plan tools copy plan code verbatim.
MD010:
code_blocks: false
MD013: false
MD024:
siblings_only: true
MD025:
front_matter_title: ""
# The docs standard prescribes <picture> for themed images, and the org profile opens with <img>.
MD033:
allowed_elements: [picture, source, img]
# Journal entries, PR templates, Claude skill files and the org profile banner open without an H1 on purpose;
# every other doc's title is checked by the docs standard's front matter rule.
MD041: false
@@ -0,0 +1,4 @@
# The shellcheck-py hook passes no -x, so SC1091 fires when a sourced file is not among the hook's
# inputs (a commit that changes only the script that sources it), even with source-path directives.
# This makes shellcheck follow and check sourced files: stricter, not looser.
external-sources=true
@@ -0,0 +1,5 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
+40
View File
@@ -28,6 +28,9 @@ check ".markdownlint.yaml is the piece's file" cmp -s "$PIECES/markdown.files/.m
mkdir "$T/sh"
"$S" --org ouroboric-dev --ci shell "$T/sh"
check "--ci shell: gitleaks then shellcheck-py" [ "$(repos "$T/sh")" = "gitleaks shellcheck-py " ]
check "--ci shell: it writes the config, lint.yml and .shellcheckrc" \
[ "$(files "$T/sh")" = "./.github/workflows/lint.yml ./.pre-commit-config.yaml ./.shellcheckrc " ]
check ".shellcheckrc is the piece's file" cmp -s "$PIECES/shell.files/.shellcheckrc" "$T/sh/.shellcheckrc"
mkdir "$T/dup"
"$S" --org ouroboric-dev --ci gitleaks,gitleaks "$T/dup"
check "--ci gitleaks,gitleaks: one gitleaks" [ "$(repos "$T/dup")" = "gitleaks " ]
@@ -128,6 +131,43 @@ ADD_CI_PIECES="$T/clash" "$S" --org ouroboric-dev --ci a,b "$T/out-clash" 2>/dev
caseclash() { [ "$rcc" = 2 ] && empty "$T/out-clash"; }
check "two pieces with the same file are refused" caseclash
# --upgrade: replace the known gitleaks-only config, and nothing else.
KNOWN="$HERE/../templates/ci/upgrade/gitleaks-only.yaml"
known_sha() { [ "$(sha256sum "$KNOWN" | cut -c1-16)" = 7119399c9a531c4a ]; }
check "the known file is the old scaffold's" known_sha
mkdir "$T/up1" "$T/up2" "$T/up3" "$T/up4" "$T/up5"
for d in up1 up2 up3 up4 up5; do cp "$KNOWN" "$T/$d/.pre-commit-config.yaml"; done
"$S" --org ouroboric-dev --ci reuse,markdown --upgrade "$T/up1"; rcu1=$?
caseup1() {
[ "$rcu1" = 0 ] && [ "$(repos "$T/up1")" = "gitleaks reuse-tool markdownlint-cli2 " ] &&
cmp -s "$HERE/../templates/ci/lint.yml" "$T/up1/.github/workflows/lint.yml" &&
[ -f "$T/up1/.markdownlint.yaml" ] && [ "$(head -n1 "$T/up1/.pre-commit-config.yaml")" = "repos:" ]
}
check "--upgrade replaces the known gitleaks-only config" caseup1
echo ' - id: extra' >> "$T/up2/.pre-commit-config.yaml"
"$S" --org ouroboric-dev --upgrade "$T/up2" 2>"$T/up2.err"; rcu2=$?
caseup2() {
[ "$rcu2" = 2 ] && [ "$(files "$T/up2")" = "./.pre-commit-config.yaml " ] &&
[ "$(tail -n1 "$T/up2/.pre-commit-config.yaml")" = ' - id: extra' ] &&
grep -qxF "add-ci: $T/up2/.pre-commit-config.yaml is not the known gitleaks-only file; nothing was written" "$T/up2.err"
}
check "--upgrade refuses a changed config" caseup2
echo >> "$T/up3/.pre-commit-config.yaml"
"$S" --org ouroboric-dev --upgrade "$T/up3" 2>/dev/null; rcu3=$?
caseup3() { [ "$rcu3" = 2 ] && [ "$(files "$T/up3")" = "./.pre-commit-config.yaml " ] && ! cmp -s "$KNOWN" "$T/up3/.pre-commit-config.yaml"; }
check "--upgrade refuses a trailing blank line" caseup3
"$S" --org ouroboric-dev "$T/up4" 2>/dev/null; rcu4=$?
caseup4() { [ "$rcu4" = 2 ] && [ "$(files "$T/up4")" = "./.pre-commit-config.yaml " ] && cmp -s "$KNOWN" "$T/up4/.pre-commit-config.yaml"; }
check "without --upgrade the known config is refused" caseup4
mkdir -p "$T/up5/.github/workflows"
echo mine > "$T/up5/.github/workflows/lint.yml"
"$S" --org ouroboric-dev --upgrade "$T/up5" 2>/dev/null; rcu5=$?
caseup5() { [ "$rcu5" = 2 ] && [ "$(files "$T/up5")" = "./.github/workflows/lint.yml ./.pre-commit-config.yaml " ] && cmp -s "$KNOWN" "$T/up5/.pre-commit-config.yaml"; }
check "--upgrade still refuses an existing lint.yml" caseup5
"$S" --org ouroboric-dev --check --upgrade; cu=$?
caseup6() { [ "$cu" = 0 ]; }
check "--check --upgrade writes nothing and passes" caseup6
# --check.
mkdir "$T/chk"
"$S" --org ouroboric-dev --check; c1=$?
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Tests for the shared lint config (the markdown, yaml and shell pieces). scripts/tests/lint-config.test.sh
# Every case is a real run: add-ci.sh --ci markdown,yaml (or shell) in a temp git repo, then pre-commit run --all-files.
# They need the network the first time, as add-ci.test.sh does.
# shellcheck disable=SC2329,SC2016 # the case functions are called by check; the printf texts hold literal backticks
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
S="$HERE/../add-ci.sh"
T="$(mktemp -d)"
trap 'rm -rf -- "$T"' EXIT
fail=0
check() { local d="$1"; shift; if "$@"; then echo "ok $d"; else echo "FAIL $d"; fail=1; fi; }
pc() { uvx -q --from 'pre-commit==4.6.2' pre-commit "$@"; }
# mk <name> [pieces]: a git repo with the pieces (default markdown,yaml). The caller adds files, then runs lint.
mk() {
mkdir -p "$T/$1"
git -C "$T/$1" init -q -b main
"$S" --org ouroboric-dev --ci "${2:-markdown,yaml}" "$T/$1"
}
# lint <name>: stage everything and run the hooks. Output goes to $T/<name>.log; the exit code is returned.
lint() {
git -C "$T/$1" add -- .
(cd "$T/$1" && pc run --all-files >"$T/$1.log" 2>&1)
}
passes() { lint "$1" || { cat "$T/$1.log"; return 1; }; }
fails_on() { # fails_on <name> <text>: the run fails, and the output holds <text>
! lint "$1" && grep -q -- "$2" "$T/$1.log"
}
# MD010: a tab inside a fenced code block is fine (gofmt), a tab in prose is not.
mk gotab
printf '# Doc\n\nCode:\n\n```go\nfunc main() {\n\treturn\n}\n```\n' > "$T/gotab/doc.md"
check "a tab in a Go code block passes" passes gotab
mk prosetab
printf '# Doc\n\nSome\ttext with a tab.\n' > "$T/prosetab/doc.md"
check "a tab in prose fails" fails_on prosetab MD010
# MD033: <picture> is allowed, <div> is not.
mk picture
printf '# Doc\n\n<picture>\n<source srcset="a.png">\n<img src="b.png" alt="b">\n</picture>\n' > "$T/picture/doc.md"
check "<picture> passes" passes picture
mk div
printf '# Doc\n\n<div>text</div>\n' > "$T/div/doc.md"
check "<div> fails" fails_on div MD033
# MD041: a file may open without an H1.
mk noh1
printf -- '---\ndate: 2026-10-18\n---\n\n## Challenge\n\nText.\n' > "$T/noh1/entry.md"
check "a journal-style file without an H1 passes" passes noh1
# markdownlint-cli2 ignores skip testdata, and the shared rules still apply to everything else.
mk mdx
printf 'ignores: ["testdata/**"]\n' > "$T/mdx/.markdownlint-cli2.yaml"
mkdir "$T/mdx/testdata" "$T/mdx/docs"
printf '# Doc\n\n<div>broken</div>\n' > "$T/mdx/testdata/x.md"
mdx() { # only docs/y.md is flagged, and by a rule from the shared file
! lint mdx && grep -q '^docs/y.md:' "$T/mdx.log" && ! grep -q '^testdata/x.md:' "$T/mdx.log" && grep -q MD033 "$T/mdx.log" \
&& ! grep -q '^docs/ok.md:' "$T/mdx.log"
}
printf '# Doc\n\n<div>broken</div>\n' > "$T/mdx/docs/y.md"
# ok.md breaks markdownlint's built-in defaults (MD033 <picture>, MD013 line over 80) and passes only with the shared bends.
printf '# Doc\n\n<picture>\n<img src="b.png" alt="b">\n</picture>\n\n%s\n' "$(printf 'word %.0s' $(seq 1 24))word" > "$T/mdx/docs/ok.md"
check "markdownlint-cli2 ignores skip testdata and keep the shared rules" mdx
# A .yamllint that extends the shared config skips testdata and keeps the shared rules.
mk yx
printf 'extends: .yamllint.yaml\nignore: testdata/\n' > "$T/yx/.yamllint"
mkdir "$T/yx/testdata"
long="$(printf 'a%.0s' $(seq 1 130))"
printf 'key: %s\n' "$long" > "$T/yx/testdata/x.yaml"
printf 'key: %s\n' "$long" > "$T/yx/y.yaml"
# ok.yaml has 100 characters and no "---": yamllint's built-in defaults reject it, the shared config accepts it.
printf 'key: %s\n' "$(printf 'a%.0s' $(seq 1 95))" > "$T/yx/ok.yaml"
yx() { # only y.yaml is flagged, on line-length from the shared file
! lint yx && grep -q 'y.yaml' "$T/yx.log" && ! grep -q 'testdata/x.yaml' "$T/yx.log" && grep -q 'line-length' "$T/yx.log" \
&& ! grep -q 'ok.yaml' "$T/yx.log"
}
check "a .yamllint that extends the shared config skips testdata and keeps its rules" yx
# The shellcheck-py hook passes no -x, so shellcheck follows a sourced file only when that file is among its
# inputs, or through the piece's .shellcheckrc. A commit that changes only main.sh passes only main.sh.
mk src shell
printf '#!/usr/bin/env bash\n# shellcheck source-path=SCRIPTDIR\nset -euo pipefail\nHERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n# shellcheck source=lib.sh\n. "$HERE/lib.sh"\ngreet\n' > "$T/src/main.sh"
printf '#!/usr/bin/env bash\ngreet() { echo hi; }\n' > "$T/src/lib.sh"
src() {
git -C "$T/src" add -- .
(cd "$T/src" && pc run --files main.sh >"$T/src.log" 2>&1) || { cat "$T/src.log"; return 1; }
}
check "a script that sources a sibling with source-path=SCRIPTDIR passes shellcheck on its own" src
exit $fail
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Tests for md-same-text.sh. scripts/tests/md-same-text.test.sh
# Each case is a git repo in a temp folder with two commits (base, head) of one file, then a real run.
# They need the network the first time (uvx fetches the pinned markdown-it-py).
# shellcheck disable=SC2329,SC2016 # the case functions are called by check; the printf texts hold literal backticks
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
S="$HERE/../md-same-text.sh"
T="$(mktemp -d)"
trap 'rm -rf -- "$T"' EXIT
fail=0
check() { local d="$1"; shift; if "$@"; then echo "ok $d"; else echo "FAIL $d"; fail=1; fi; }
# mk <name> <base text|-> <head text|->: a repo with doc.md at base and at head ("-" = no file at that commit).
mk() {
local r="$T/$1"
mkdir -p "$r"
git -C "$r" init -q -b main
git -C "$r" config user.email [email protected]
git -C "$r" config user.name t
printf 'x\n' >"$r/keep.txt"
[ "$2" = - ] || printf '%b' "$2" >"$r/doc.md"
git -C "$r" add -- .
git -C "$r" commit -q -m base
git -C "$r" tag base
[ "$2" = - ] || rm -f -- "$r/doc.md"
[ "$3" = - ] || printf '%b' "$3" >"$r/doc.md"
git -C "$r" add -A -- .
git -C "$r" commit -q --allow-empty -m head
}
# runs <name> <want exit> <want output line>
runs() {
local out rc
out="$(cd "$T/$1" && "$S" base HEAD 2>&1)"; rc=$?
[ "$rc" = "$2" ] && [ "$out" = "$3" ]
}
c_blank() { mk blank '# T\ntext\n## H\nmore\n' '# T\n\ntext\n\n## H\n\nmore\n'; runs blank 0 'same doc.md'; }
c_table() { mk table '|a|b|\n|---|---|\n|1|2|\n' '| a | b |\n| --- | --- |\n| 1 | 2 |\n'; runs table 0 'same doc.md'; }
c_bold() { mk bold '**X**\n\ntext\n' '#### X\n\ntext\n'; runs bold 0 'same doc.md'; }
c_info() { mk info '```\nfoo\n```\n' '```text\nfoo\n```\n'; runs info 0 'same doc.md'; }
c_word() { mk word 'one two\n' 'one three\n'; runs word 1 'DIFFERS doc.md: text'; }
c_tab() { mk tab '```go\n\tx := 1\n```\n' '```go\n x := 1\n```\n'; runs tab 1 'DIFFERS doc.md: code'; }
c_trail() { mk trail '```go\nx := 1\n```\n' '```go\nx := 1 \n```\n'; runs trail 1 'DIFFERS doc.md: code'; }
c_line() { mk line '```go\nx := 1\n```\n' '```go\nx := 1\ny := 2\n```\n'; runs line 1 'DIFFERS doc.md: code'; }
c_link() { mk link '[t](http://a.com)\n' '[t](http://evil.com)\n'; runs link 1 'DIFFERS doc.md: links'; }
c_img() { mk img '![alt](a.png)\n' '![alt](b.png)\n'; runs img 1 'DIFFERS doc.md: links'; }
c_ref() { mk ref '[t][1]\n\n[1]: http://a.com\n' '[t][1]\n\n[1]: http://evil.com\n'; runs ref 1 'DIFFERS doc.md: links'; }
c_html() { mk html '<a href="x">t</a>\n' '<a href="y">t</a>\n'; runs html 1 'DIFFERS doc.md: links'; }
c_autolink() { mk autolink 'see https://a.com/x now\n' 'see <https://a.com/x> now\n'; runs autolink 0 'same doc.md'; }
c_placeholder() { mk placeholder 'Run it with <issue> set.\n' 'Run it with `<issue>` set.\n'; runs placeholder 0 'same doc.md'; }
c_rawtext() { mk rawtext 'see <abbr title="one">X</abbr>\n' 'see <abbr title="two">X</abbr>\n'; runs rawtext 1 'DIFFERS doc.md: text'; }
c_lintoff() { mk lintoff '# T\n\nA long line.\n' '# T\n\n<!-- markdownlint-disable-next-line MD013 -->\nA long line.\n'; runs lintoff 1 'DIFFERS doc.md: lintoff'; }
c_indent() { mk indent 'para\n\n\tcode x\n' 'para\n\n\tcode x\n'; runs indent 1 'DIFFERS doc.md: code'; }
c_added() { mk added - 'new\n'; runs added 1 'DIFFERS doc.md: added'; }
c_deleted() { mk deleted 'old\n' -; runs deleted 1 'DIFFERS doc.md: deleted'; }
c_nomd() { mk nomd - -; runs nomd 0 ''; }
c_badref() { (cd "$T/nomd" && "$S" nosuchref HEAD >/dev/null 2>&1; [ "$?" = 2 ]); }
c_usage() { (cd "$T/nomd" && "$S" base >/dev/null 2>&1; [ "$?" = 2 ]); }
check "a heading with blank lines added is the same" c_blank
check "|---| to | --- | is the same" c_table
check "**X** to #### X is the same" c_bold
check "a fence given an info string is the same" c_info
check "a changed word differs (text)" c_word
check "a tab changed to spaces in a fence differs (code)" c_tab
check "trailing whitespace in a fence differs (code)" c_trail
check "a line added in a fence differs (code)" c_line
check "a changed link target differs" c_link
check "a changed image src differs" c_img
check "a changed reference definition differs" c_ref
check "a changed raw HTML href differs" c_html
check "a whitespace change in an indented code block differs" c_indent
check "a bare URL wrapped as <URL> is the same" c_autolink
check "a placeholder wrapped in backticks is the same" c_placeholder
check "changed raw HTML text differs" c_rawtext
check "an added markdownlint-disable comment differs" c_lintoff
check "a file only in head reports added" c_added
check "a file only in base reports deleted" c_deleted
check "no .md change exits 0" c_nomd
check "a bad ref exits 2" c_badref
check "missing arguments exit 2" c_usage
exit $fail
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
# Tests for rollout-ci.sh against local bare repos, a bot stub, fake-gh and setup and check stubs. scripts/tests/rollout-ci.test.sh
# shellcheck disable=SC2329 # the helper functions are called by check
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RC="$HERE/../rollout-ci.sh"
T="$(mktemp -d)"
trap 'rm -rf -- "$T"' EXIT
fail=0
check() { local d="$1"; shift; if "$@"; then echo "ok $d"; else echo "FAIL $d"; fail=1; fi; }
TOKEN=ghp_FAKE_rollout_token
B="$T/git/ouroboric-dev"
export GIT_AUTHOR_NAME=Founder GIT_AUTHOR_EMAIL=[email protected] GIT_COMMITTER_NAME=Founder GIT_COMMITTER_EMAIL=[email protected]
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export ROLLOUT_ROOT="$T/root" ROLLOUT_GIT="file://$T/git"
# Stubs. Each logs its arguments and the bytes it read from stdin.
mkdir -p "$T/bin" "$T/prs" "$B" "$ROLLOUT_ROOT"
cp "$HERE/fake-gh" "$T/bin/gh" && chmod +x "$T/bin/gh"
# The git wrapper logs its arguments. When $T/move holds "<repo> <sha>", a push first moves that repo's
# remote base-ci to <sha>, as if someone else pushed between rollout-ci.sh's ls-remote and its push.
REAL_GIT="$(command -v git)"
cat > "$T/bin/git" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$T/git.log"
if [ -s "$T/move" ] && [[ " \$* " == *" push "* ]]; then
read -r repo sha < "$T/move"
"$REAL_GIT" -C "$B/\$repo.git" update-ref refs/heads/base-ci "\$sha"
: > "$T/move"
fi
exec "$REAL_GIT" "\$@"
EOF
# The bot stub answers "gh pr list" from $T/prs/<repo> and "gh pr create" by writing it.
cat > "$T/bot" <<EOF
#!/usr/bin/env bash
jq -cn --arg stdin "\$(cat | wc -c)" '{args: \$ARGS.positional, stdin: \$stdin}' --args -- "\$@" >> "$T/bot.log"
repo="\$1"
case "\$3 \$4" in
"pr list") cat "$T/prs/\$repo" 2>/dev/null; true ;;
"pr create") echo "https://github.com/ouroboric-dev/\$repo/pull/1" | tee "$T/prs/\$repo" ;;
*) exit 9 ;;
esac
EOF
cat > "$T/setup" <<EOF
#!/usr/bin/env bash
echo "\$* stdin=\$(cat | wc -c)" >> "$T/setup.log"
[ -z "\${SETUP_FAILS:-}" ] || exit 1
echo "set up \${!#}"
EOF
cat > "$T/check" <<EOF
#!/usr/bin/env bash
echo "\$* stdin=\$(cat | wc -c)" >> "$T/check.log"
if [ -n "\${CHECK_FAILS:-}" ]; then echo "FAIL \${!#} ruleset: stub"; exit 1; fi
echo "PASS \${!#} ruleset"
EOF
chmod +x "$T/bin/git" "$T/bot" "$T/setup" "$T/check"
export PATH="$T/bin:$PATH" ROLLOUT_BOT="$T/bot" ROLLOUT_SETUP="$T/setup" ROLLOUT_CHECK="$T/check"
: > "$T/bot.log"; : > "$T/setup.log"; : > "$T/check.log"; : > "$T/git.log"
# fake-gh answers the lint.yml lookups: a missing fixture is a 404.
export FAKE_GH_DIR="$T/gh"; mkdir -p "$FAKE_GH_DIR/GET" "$FAKE_GH_DIR/ERR"
lint() { echo "repos_$1_$2_contents_.github_workflows_lint.yml"; }
# prepare <repo> [branch]: a bare remote with one commit on main, and a clone of it under the root,
# with a base-ci branch holding one more commit unless the second argument is "none".
prepare() {
local w="$ROLLOUT_ROOT/$1"
git init -q --bare -b main "$B/$1.git"
git init -q -b main "$w"
echo "# $1" > "$w/README.md"
git -C "$w" add README.md && git -C "$w" commit -q -m first
git -C "$w" push -q "$B/$1.git" main
[ "${2:-}" = none ] && return
git -C "$w" checkout -q -b base-ci
echo "ci" > "$w/ci.txt"
git -C "$w" add ci.txt && git -C "$w" commit -q -m "Base CI"
}
for r in alpha beta epsilon zeta eta .github theta; do prepare "$r"; done
prepare gamma none
local_sha() { git -C "$ROLLOUT_ROOT/$1" rev-parse base-ci; }
remote_sha() { git -C "$B/$1.git" rev-parse -q --verify refs/heads/base-ci; }
creates() { jq -s '[.[]|select(.args[2:4]==["pr","create"])]|length' "$T/bot.log"; }
creates_for() { jq -s --arg r "$1" '[.[]|select(.args[0]==$r and .args[2:4]==["pr","create"])]|length' "$T/bot.log"; }
jqs() { jq -se "$@" >/dev/null; }
# no_token: the token is in no output, and in no argument to git, the bot or the stubs.
no_token() { ! grep -q "$TOKEN" "$T/out" "$T/err" "$T/git.log" "$T/bot.log" "$T/setup.log" "$T/check.log"; }
ack() { git -C "$ROLLOUT_ROOT/$1" rev-parse -q --verify refs/rollout-ci/base-ci; }
url() { echo "rollout-ci: $1: https://github.com/ouroboric-dev/$1/pull/1"; }
# push: two prepared repos
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push alpha beta <<<"typed input" > "$T/out" 2> "$T/err"; rc=$?
check "push two prepared repos: exit 0" [ "$rc" = 0 ]
check "push two prepared repos: both base-ci branches are on the remotes" \
bash -c "[ '$(remote_sha alpha)' = '$(local_sha alpha)' ] && [ '$(remote_sha beta)' = '$(local_sha beta)' ]"
check "push two prepared repos: two PRs are opened through the bot" [ "$(creates)" = 2 ]
check "push two prepared repos: each PR is base main, head base-ci, titled 'Base CI: lint required'" \
jqs 'map(select(.args[2:4]==["pr","create"])) | length == 2 and all(.args[4:12] == ["--repo","ouroboric-dev/"+.args[0],"--base","main","--head","base-ci","--title","Base CI: lint required"])' "$T/bot.log"
check "push two prepared repos: the PR body names the proposal and the protect step" \
jqs 'map(select(.args[2:4]==["pr","create"])) | length == 2 and all(.args[12] == "--body" and (.args[13] | test("base-ci-rollout/proposal.md") and test("rollout-ci.sh protect")))' "$T/bot.log"
check "push two prepared repos: the bot looks for an open PR on base-ci first" \
jqs 'map(select(.args[2:4]==["pr","list"])) | length == 2 and all(.args[4:] == ["--repo","ouroboric-dev/"+.args[0],"--head","base-ci","--state","open","--json","url","-q",".[0].url"])' "$T/bot.log"
check "push two prepared repos: both URLs are printed" [ "$(cat "$T/out")" = "$(url alpha; url beta)" ]
check "push: stdin never reaches the bot" jqs 'all(.stdin == "0")' "$T/bot.log"
check "push: the token never appears in any output or argument" no_token
# push again: nothing changed
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push alpha beta > "$T/out" 2> "$T/err"; rc=$?
check "push again with nothing changed: exit 0" [ "$rc" = 0 ]
check "push again with nothing changed: it prints up to date and the open PR's URL" \
[ "$(cat "$T/out")" = "$(printf 'rollout-ci: alpha: up to date\n%s\nrollout-ci: beta: up to date\n%s' "$(url alpha)" "$(url beta)")" ]
check "push again with nothing changed: no second PR" [ "$(creates)" = 2 ]
# push .github: a repo name may start with a dot
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push .github > "$T/out" 2> "$T/err"; rc=$?
check "push .github: exit 0, base-ci is on the remote, and its PR URL is printed" \
bash -c "[ $rc = 0 ] && [ '$(remote_sha .github)' = '$(local_sha .github)' ] && [ '$(cat "$T/out")' = '$(url .github)' ]"
# push with a global insteadOf that sends the org's URLs elsewhere (the founder's https-to-SSH rule):
# rollout-ci.sh's own rule is longer, so it wins, and the push still goes to $T/git with the token.
git init -q --bare -b main "$T/wrong/ouroboric-dev/theta.git"
printf '[url "file://%s/wrong/"]\n\tinsteadOf = file://%s/git/\n' "$T" "$T" > "$T/insteadof.gitconfig"
GIT_CONFIG_GLOBAL="$T/insteadof.gitconfig" GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push theta > "$T/out" 2> "$T/err"; rc=$?
check "push with a global insteadOf for the org's URLs: exit 0, base-ci lands on the real remote" \
bash -c "[ $rc = 0 ] && [ '$(remote_sha theta)' = '$(local_sha theta)' ]"
check "push with a global insteadOf for the org's URLs: nothing reaches the rewritten remote" \
bash -c "! git -C '$T/wrong/ouroboric-dev/theta.git' rev-parse -q --verify refs/heads/base-ci >/dev/null"
# push after a local rebase: the remote is updated, and the open PR is reused
git -C "$ROLLOUT_ROOT/alpha" commit -q --amend -m "Base CI, rebased"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push alpha > "$T/out" 2> "$T/err"; rc=$?
check "push after a local rebase: exit 0" [ "$rc" = 0 ]
check "push after a local rebase: the remote is updated" [ "$(remote_sha alpha)" = "$(local_sha alpha)" ]
check "push after a local rebase: the open PR is reused, not a second one" \
bash -c "[ '$(creates_for alpha)' = 1 ] && [ '$(cat "$T/out")' = '$(url alpha)' ]"
check "push after a local rebase: the token never appears in any output or argument" no_token
# push after a local rebase, while someone else moves the remote branch: the lease refuses it
git -C "$ROLLOUT_ROOT/alpha" commit -q --amend -m "Base CI, rebased again"
other="$(git -C "$B/alpha.git" commit-tree -p main -m "someone else" "main^{tree}")"
echo "alpha $other" > "$T/move"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push alpha beta > "$T/out" 2> "$T/err"; rc=$?
check "push when someone else moved the remote branch: exit 1" [ "$rc" = 1 ]
check "push when someone else moved the remote branch: their commit is not overwritten" [ "$(remote_sha alpha)" = "$other" ]
check "push when someone else moved the remote branch: stderr names the repo" grep -q '^rollout-ci: alpha: ' "$T/err"
check "push when someone else moved the remote branch: no PR line for it, and the next repo still runs" \
[ "$(cat "$T/out")" = "$(printf 'rollout-ci: beta: up to date\n%s' "$(url beta)")" ]
check "push when someone else moved the remote branch: no second PR" [ "$(creates_for alpha)" = 1 ]
check "push when someone else moved the remote branch: the token never appears in any output or argument" no_token
# push again: the remote moved between two runs, so push refuses until the move is folded in
git -C "$ROLLOUT_ROOT/alpha" commit -q --amend -m "Base CI, rebased a third time"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push alpha > "$T/out" 2> "$T/err"; rc=$?
check "push when the remote moved between two runs: exit 1" [ "$rc" = 1 ]
check "push when the remote moved between two runs: their commit is not overwritten" [ "$(remote_sha alpha)" = "$other" ]
check "push when the remote moved between two runs: stderr names the repo, both SHAs and the acknowledgement" \
grep -qx "rollout-ci: alpha: the remote base-ci moved: GitHub has $other, the last push was $(ack alpha); fold it in, then git update-ref refs/rollout-ci/base-ci $other" "$T/err"
check "push when the remote moved between two runs: nothing on stdout, no second PR" \
bash -c "[ ! -s '$T/out' ] && [ '$(creates_for alpha)' = 1 ]"
git -C "$ROLLOUT_ROOT/alpha" fetch -q "$B/alpha.git" base-ci
git -C "$ROLLOUT_ROOT/alpha" rebase -q "$other"
git -C "$ROLLOUT_ROOT/alpha" update-ref refs/rollout-ci/base-ci "$other"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push alpha > "$T/out" 2> "$T/err"; rc=$?
check "push after the move is acknowledged and the branch rebased: exit 0" [ "$rc" = 0 ]
check "push after the move is acknowledged: the remote is updated, and the last push is recorded" \
bash -c "[ '$(remote_sha alpha)' = '$(local_sha alpha)' ] && [ '$(ack alpha)' = '$(local_sha alpha)' ]"
check "push after the move is acknowledged: the open PR is reused" \
bash -c "[ '$(creates_for alpha)' = 1 ] && [ '$(cat "$T/out")' = '$(url alpha)' ]"
# push again: the remote branch was deleted between two runs
git -C "$B/beta.git" update-ref -d refs/heads/base-ci
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push beta > "$T/out" 2> "$T/err"; rc=$?
check "push when the remote branch was deleted between two runs: exit 1, it is not pushed again" \
bash -c "[ $rc = 1 ] && ! git -C '$B/beta.git' rev-parse -q --verify refs/heads/base-ci >/dev/null"
check "push when the remote branch was deleted between two runs: the acknowledgement is to drop the record" \
grep -qx "rollout-ci: beta: the remote base-ci moved: GitHub has none, the last push was $(ack beta); fold it in, then git update-ref -d refs/rollout-ci/base-ci" "$T/err"
git -C "$ROLLOUT_ROOT/beta" update-ref -d refs/rollout-ci/base-ci
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push beta > "$T/out" 2> "$T/err"; rc=$?
check "push after the deletion is acknowledged: exit 0, base-ci is back" \
bash -c "[ $rc = 0 ] && [ '$(remote_sha beta)' = '$(local_sha beta)' ]"
# push after the merge: main holds lint.yml, so push does nothing
echo '{"name":"lint.yml"}' > "$FAKE_GH_DIR/GET/$(lint ouroboric-dev zeta).json"
echo 'HTTP 500' > "$FAKE_GH_DIR/ERR/$(lint ouroboric-dev eta)"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push zeta > "$T/out" 2> "$T/err"; rc=$?
check "push when lint.yml is already on main: exit 0" [ "$rc" = 0 ]
check "push when lint.yml is already on main: it says to run protect" \
[ "$(cat "$T/out")" = "rollout-ci: zeta: lint.yml is already on main; run protect" ]
check "push when lint.yml is already on main: nothing is pushed, no PR is touched" \
bash -c "! git -C '$B/zeta.git' rev-parse -q --verify refs/heads/base-ci >/dev/null && jq -se 'all(.args[0] != \"zeta\")' '$T/bot.log' >/dev/null"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push eta > "$T/out" 2> "$T/err"; rc=$?
check "push when the lint.yml lookup fails: exit 1, named, nothing pushed, no PR touched" \
bash -c "[ $rc = 1 ] && grep -q '^rollout-ci: eta: .*HTTP 500' '$T/err' && ! git -C '$B/eta.git' rev-parse -q --verify refs/heads/base-ci >/dev/null && jq -se 'all(.args[0] != \"eta\")' '$T/bot.log' >/dev/null"
check "push: the token never appears in any output or argument" no_token
# a first push while someone else creates the remote branch: "must not exist" refuses it
echo "epsilon $(git -C "$B/epsilon.git" rev-parse main)" > "$T/move"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push epsilon > "$T/out" 2> "$T/err"; rc=$?
check "a first push when someone else created the remote branch: exit 1" [ "$rc" = 1 ]
check "a first push when someone else created the remote branch: it is not overwritten" \
[ "$(remote_sha epsilon)" = "$(git -C "$B/epsilon.git" rev-parse main)" ]
check "a first push when someone else created the remote branch: no PR is opened" [ "$(creates_for epsilon)" = 0 ]
# a repo without a local base-ci branch, and one with no clone at all
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev push gamma delta beta > "$T/out" 2> "$T/err"; rc=$?
check "a repo without a base-ci branch: exit 1" [ "$rc" = 1 ]
check "a repo without a base-ci branch: it is skipped and named" \
[ "$(cat "$T/err")" = "$(printf 'rollout-ci: gamma: no base-ci branch in %s; skipped\nrollout-ci: delta: no base-ci branch in %s; skipped' "$ROLLOUT_ROOT/gamma" "$ROLLOUT_ROOT/delta")" ]
check "a repo without a base-ci branch: nothing is pushed for it" bash -c "! git -C '$B/gamma.git' rev-parse -q --verify refs/heads/base-ci >/dev/null"
check "a repo without a base-ci branch: the bot is never called for it" \
jqs 'all(.args[0] != "gamma" and .args[0] != "delta")' "$T/bot.log"
check "a repo without a base-ci branch: the other repos still run" [ "$(cat "$T/out")" = "$(printf 'rollout-ci: beta: up to date\n%s' "$(url beta)")" ]
# usage
for args in "" "push" "pull alpha" "push -x" "push ../alpha" "push .." "push ." "protect .."; do
# shellcheck disable=SC2086 # the words are the arguments
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev $args > "$T/out" 2> "$T/err"; rc=$?
check "usage '$args': exit 2 and no output" bash -c "[ $rc = 2 ] && [ ! -s '$T/out' ]"
done
env -u GH_TOKEN "$RC" --org ouroboric-dev push alpha > "$T/out" 2> "$T/err"; rc=$?
check "no GH_TOKEN: exit 2" [ "$rc" = 2 ]
# protect: 200, 404 and 500
for r in alpha beta gamma; do echo '{}' > "$FAKE_GH_DIR/GET/repos_ouroboric-dev_$r.json"; done
echo '{}' > "$FAKE_GH_DIR/GET/repos_acme_alpha.json"
echo '{"name":"lint.yml"}' > "$FAKE_GH_DIR/GET/$(lint ouroboric-dev alpha).json"
echo 'HTTP 500' > "$FAKE_GH_DIR/ERR/$(lint ouroboric-dev gamma)"
CONFIG="$(cd "$HERE/../orgs" && pwd)/ouroboric-dev.json"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev protect alpha beta gamma <<<"typed input" > "$T/out" 2> "$T/err"; rc=$?
check "protect 200, 404 and 500: exit 1" [ "$rc" = 1 ]
check "protect: setup runs only for the repo whose lint.yml is on main, with the config, and no stdin" \
[ "$(cat "$T/setup.log")" = "--config $CONFIG alpha stdin=0" ]
check "protect: the ruleset check runs only for it, with no stdin" [ "$(cat "$T/check.log")" = "--check ruleset alpha stdin=0" ]
check "protect: it prints the setup and the check, and says not yet for the 404" \
[ "$(cat "$T/out")" = "$(printf 'set up alpha\nPASS alpha ruleset\nrollout-ci: beta: lint.yml is not on main yet; not protected')" ]
check "protect: the 500 is printed and named" grep -q '^rollout-ci: gamma: .*HTTP 500' "$T/err"
check "protect: the token never appears in any output or argument" no_token
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev protect nosuch beta > "$T/out" 2> "$T/err"; rc=$?
check "protect a repo that doesn't exist: exit 1, named" \
bash -c "[ $rc = 1 ] && [ \"\$(cat '$T/err')\" = 'rollout-ci: nosuch: no such repo in ouroboric-dev' ]"
check "protect a repo that doesn't exist: setup never runs for it, the next repo still runs" \
bash -c "! grep -q nosuch '$T/setup.log' && grep -q '^rollout-ci: beta: ' '$T/out'"
: > "$T/setup.log"; : > "$T/check.log"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev protect alpha beta > "$T/out" 2> "$T/err"; rc=$?
check "protect 200 and 404 only: exit 0" [ "$rc" = 0 ]
: > "$T/setup.log"; : > "$T/check.log"
echo '{}' > "$FAKE_GH_DIR/GET/repos_ouroboric-dev_.github.json"
echo '{"name":"lint.yml"}' > "$FAKE_GH_DIR/GET/$(lint ouroboric-dev .github).json"
GH_TOKEN=$TOKEN "$RC" --org ouroboric-dev protect .github > "$T/out" 2> "$T/err"; rc=$?
check "protect .github: exit 0, setup and the ruleset check run for it" \
bash -c "[ $rc = 0 ] && [ \"\$(cat '$T/setup.log')\" = '--config $CONFIG .github stdin=0' ] && [ \"\$(cat '$T/check.log')\" = '--check ruleset .github stdin=0' ]"
: > "$T/setup.log"; : > "$T/check.log"
GH_TOKEN=$TOKEN CHECK_FAILS=1 "$RC" --org ouroboric-dev protect alpha beta > "$T/out" 2> "$T/err"; rc=$?
check "protect when the ruleset check fails: exit 1" [ "$rc" = 1 ]
check "protect when the ruleset check fails: its result is printed" grep -qx 'FAIL alpha ruleset: stub' "$T/out"
: > "$T/setup.log"; : > "$T/check.log"
GH_TOKEN=$TOKEN SETUP_FAILS=1 "$RC" --org ouroboric-dev protect alpha > "$T/out" 2> "$T/err"; rc=$?
check "protect when setup fails: exit 1, no check, the repo is named" \
bash -c "[ $rc = 1 ] && [ ! -s '$T/check.log' ] && grep -q '^rollout-ci: alpha: ' '$T/err'"
: > "$T/setup.log"; : > "$T/check.log"
jq '.org = "acme" | del(.licence_dir)' "$CONFIG" > "$T/acme.json"
echo '{"name":"lint.yml"}' > "$FAKE_GH_DIR/GET/$(lint acme alpha).json"
GH_TOKEN=$TOKEN env -u ROLLOUT_BOT "$RC" --config "$T/acme.json" protect alpha > "$T/out" 2> "$T/err"; rc=$?
check "protect in another org, without ROLLOUT_BOT: setup runs, the ouroboric-dev ruleset check does not" \
bash -c "[ $rc = 0 ] && [ \"\$(cat '$T/setup.log')\" = '--config $T/acme.json alpha stdin=0' ] && [ ! -s '$T/check.log' ]"
# push in another org without ROLLOUT_BOT: as-bot.sh serves ouroboric-dev only, so nothing is touched
: > "$T/git.log"; : > "$T/bot.log"
GH_TOKEN=$TOKEN env -u ROLLOUT_BOT "$RC" --config "$T/acme.json" push alpha > "$T/out" 2> "$T/err"; rc=$?
check "push in another org without ROLLOUT_BOT: exit 2, and stderr names the org" \
bash -c "[ $rc = 2 ] && [ ! -s '$T/out' ] && [ \"\$(cat '$T/err')\" = 'rollout-ci: as-bot.sh serves ouroboric-dev only; set ROLLOUT_BOT for acme' ]"
check "push in another org without ROLLOUT_BOT: no git command and no bot call" \
bash -c "[ ! -s '$T/git.log' ] && [ ! -s '$T/bot.log' ]"
exit $fail