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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This repo is the **dev-env infrastructure repository** — it contains documenta
- `docs/token-rotation.md` — Retired 2026-10-12: no repo runs Claude in CI and `CLAUDE_CODE_OAUTH_TOKEN` is deleted; never contains a token
- `docs/runbooks/fleet-probe-token-scopes.md` — The two fine-grained-PAT properties a fleet probe needs (`Administration: Read-only` + All-repositories), and why an under-scoped token returns wrong numbers instead of errors
- `scripts/org-migration/` — Snapshot/transfer/verify tooling for the 2026-09 org migration; tests in `scripts/org-migration/tests/run-tests.sh`
- `scripts/dependabot-digest/` — Collects open Dependabot PRs across all three owners and upserts one digest issue describing the queue; run by `.github/workflows/dependabot-digest.yml`. Credentials (a GitHub App) are installed by hand: see `docs/runbooks/dependabot-digest-credentials.md`
- `scripts/dependabot-digest/` — Collects open Dependabot PRs across all three owners and upserts one digest issue describing the queue, plus each repo's standards-check warnings and default-branch protection (`standards.sh`); run by `.github/workflows/dependabot-digest.yml`. Credentials (a GitHub App) are installed by hand: see `docs/runbooks/dependabot-digest-credentials.md`
- `scripts/site-monitor/` — Every 15 minutes, checks each production Netlify site in `sites.json` and its latest `site-check` run; keeps one `site-down` issue and pushes ntfy.sh alerts. Run by `.github/workflows/site-monitor.yml`, which a launchd job on MIMOLETTE starts through `dispatch-monitor.sh` because GitHub's cron never fired. The `NTFY_TOPIC` secret and the dispatch token are installed by hand: see `docs/runbooks/site-monitor-ntfy.md`
- `.project-hooks/pre-commit` and `.project-hooks/pre-push` — Project-specific git hook extensions, run by the global hooks at `~/.config/git/hooks/` when executable
- `.claude/` is ignored, as in every fleet repo (dev-env#178). The scaffold the post-checkout hook drops there (`config.sh.template`, `README.md`) comes from `smartwatermelon/dotfiles` `git/template/.claude-template/`; edit it there.
Expand Down
4 changes: 4 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,10 @@ for org-owned repos until Andrew re-runs Part D there.
scheduled run passed 2026-10-02, and plan step 7 (delete the three
`DIGEST_TOKEN_*` secrets, revoke their PATs) is done. Since `dev-env#182`
the digest also carries a "Standards warnings" section.
Since 2026-10-21 it also carries a "Branch protection" section (closes
`dev-env#211`): repos unprotected or not requiring standards-check, with
exempt classes counted. Whether the App token can read `.protection` is
unverified until the first run after merge.

Earlier, all daily runs from 09-26 failed on `nightowlstudiollc` check-run
access, so nothing was published.
Expand Down
83 changes: 83 additions & 0 deletions scripts/dependabot-digest/render.sh
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,91 @@ render_standards() {
fi
}

# dev-env#211. An unreadable repo is never counted as protected or as
# unprotected; if none was read, say so.
render_protection() {
local file="$1" recs surveyed required exempt flagged unreadable by_class owners rows
echo "## Branch protection"
echo
if [[ ! -r "${file}" ]] || ! jq -e -s 'type == "array"' "${file}" >/dev/null 2>&1; then
echo "**Not checked.** The survey produced no readable result, so no repository's protection was verified."
echo
return 0
fi
# A record without a protection object (an older or crashed survey) is
# unreadable, not clean.
if ! recs="$(jq -cs '
[.[] | select(type == "object" and (.repo | type == "string") and .state != "archived")
| .p = (if (.protection | type) == "object"
and (.protection.state | type) == "string"
then .protection
else {state: "unreadable", detail: "no protection result recorded", exemptClass: null} end)]
' "${file}" 2>/dev/null)" || [[ -z "${recs}" ]]; then
echo "**Not checked.** The survey output could not be summarized, so no repository's protection was verified."
echo
return 0
fi
owners="$(jq -rs '[.[] | select(type == "object" and .repo == null and .state == "unlisted") | .owner] | unique | join(", ")' "${file}" 2>/dev/null)"
surveyed="$(jq 'length' <<<"${recs}")"
required="$(jq '[.[] | select(.p.state == "required")] | length' <<<"${recs}")"
exempt="$(jq '[.[] | select(.p.state == "exempt")] | length' <<<"${recs}")"
unreadable="$(jq '[.[] | select(.p.state != "required" and .p.state != "exempt"
and .p.state != "unprotected" and .p.state != "protected-no-standards")] | length' <<<"${recs}")"
flagged=$((surveyed - required - exempt - unreadable))

echo "Whether each repository's default branch is protected and requires \`standards-check / run-standards-check\`. Exempt repositories are unprotected by a recorded decision for their class; they are counted, not listed."
echo
if [[ "${surveyed}" -eq 0 ]]; then
echo "**No repository was surveyed**, so no protection was verified."
echo
elif [[ "${unreadable}" -eq "${surveyed}" ]]; then
# Zero gaps out of zero reads is not "all protected".
echo "**Protection could not be verified for any of ${surveyed} repositories.** Every read failed or returned no protection settings. The likely cause is a token without permission to read branch protection. Nothing here means the fleet is protected or unprotected."
echo
else
echo "**${surveyed} repositories surveyed**: ${required} require standards-check, ${exempt} exempt, **${flagged} need attention, ${unreadable} unreadable.**"
echo
if [[ "${exempt}" -gt 0 ]]; then
by_class="$(jq -r '[.[] | select(.p.state == "exempt")] | group_by(.p.exemptClass)
| map("- \(.[0].p.exemptClass // "unknown class"): \(length) — \(.[0].p.detail // "no reason recorded")") | join("\n")' <<<"${recs}")"
echo "Exempt by class:"
echo
echo "${by_class}"
echo
fi
if [[ "${flagged}" -eq 0 && "${unreadable}" -eq 0 ]]; then
echo "No gaps: every surveyed repository requires standards-check or is exempt."
echo
fi
fi
if [[ "${flagged}" -gt 0 || "${unreadable}" -gt 0 ]]; then
if ! rows="$(jq -r '
def cell: tostring | gsub("[\r\n]+"; " ") | gsub("\\|"; "\\|");
def order: {"unprotected": 0, "protected-no-standards": 1}[.p.state] // 2;
def name: {"unprotected": "UNPROTECTED", "protected-no-standards": "standards-check not required"}[.p.state] // "UNREADABLE";
sort_by(order, .repo)[]
| select(.p.state != "required" and .p.state != "exempt")
| "| [" + .repo + "](https://github.com/" + .repo + ") | " + name
+ (if .p.detail then ": " + (.p.detail | cell) else "" end) + " |"
' <<<"${recs}")" || [[ -z "${rows}" ]]; then
echo "**Some repositories need attention, but listing them failed.** See the run log."
echo
else
echo "| Repo | Protection |"
echo "| --- | --- |"
echo "${rows}"
echo
fi
fi
if [[ -n "${owners}" ]]; then
echo "**Not listed, so not surveyed:** ${owners}. Protection of their repositories is unknown."
echo
fi
}

if [[ -n "${standards_file}" ]]; then
render_standards "${standards_file}"
render_protection "${standards_file}"
fi

echo "---"
Expand Down
111 changes: 105 additions & 6 deletions scripts/dependabot-digest/standards.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Usage: standards.sh <owner>. Prints one JSON record per repo: standards-check
# annotations, or the reason none were read.
# annotations, or the reason none were read, plus the default branch's
# protection state (dev-env#211).
set -uo pipefail
unset CDPATH

Expand Down Expand Up @@ -34,17 +35,18 @@ emit() {
--arg detail "$3" --arg source "${4-}" --arg url "${5-}" \
--argjson ann "${6:-[]}" --arg runAt "${7-}" \
--arg rulesAt "${RULES_AT}" --arg rulesErr "${RULES_ERR}" \
--argjson prot "${PROT_JSON:-null}" \
'{owner: $owner, repo: (if $repo == "" then null else $repo end),
state: $state, detail: $detail,
source: (if $source == "" then null else $source end),
runUrl: (if $url == "" then null else $url end),
runAt: (if $runAt == "" then null else $runAt end),
rulesAt: (if $rulesAt == "" then null else $rulesAt end),
rulesErr: (if $rulesErr == "" then null else $rulesErr end),
annotations: $ann}' 2>/dev/null)"
# If jq fails, the repo must still appear.
annotations: $ann, protection: $prot}' 2>/dev/null)"
# If jq fails, the repo must still appear. Its protection is unknown.
if [[ -z "${line}" ]]; then
printf '{"owner":"%s","repo":"%s","state":"unreadable","detail":"could not encode the result","source":null,"runUrl":null,"runAt":null,"rulesAt":null,"rulesErr":null,"annotations":[]}\n' \
printf '{"owner":"%s","repo":"%s","state":"unreadable","detail":"could not encode the result","source":null,"runUrl":null,"runAt":null,"rulesAt":null,"rulesErr":null,"annotations":[],"protection":null}\n' \
"${owner}" "$1"
return 0
fi
Expand Down Expand Up @@ -121,6 +123,97 @@ run_on_commit() {
jq -c "${pick_run} // empty" <<<"${body}"
}

# dev-env#211: prints "state<TAB>detail". A withheld or odd field is
# unreadable, never a finding or a clean result.
read -r -d '' prot_verdict <<'JQ'
def std: . == "run-standards-check" or endswith(" / run-standards-check");
if (.protected | type) != "boolean" then "unreadable\tthe branch reports no protected flag"
elif .protected == false then "unprotected\tthe default branch has no protection"
else .protection as $p | $p.required_status_checks as $rsc
| if ($rsc.contexts | type) != "array"
or (all($rsc.contexts[]; type == "string") | not)
or ($p.enabled | type) != "boolean"
or ($rsc.enforcement_level | type) != "string"
then "unreadable\tprotected, but the protection settings are not readable"
elif $p.enabled == false or $rsc.enforcement_level == "off"
then "protected-no-standards\tprotected, but no required check is enforced"
elif any($rsc.contexts[]; std) then "required\t"
elif ($rsc.contexts | length) == 0
then "protected-no-standards\tprotected, but requires no status checks"
else "protected-no-standards\trequires only: " + ($rsc.contexts | join(", "))
end
end
JQ

# Unprotected by decision, so counted, not flagged. Classes, not repo names.
# Each reason carries its decision date; the digest prints it.
declare -A EXEMPT_REASON=(
[fork]="a fork is not ours to govern (decided 2026-10-21, dev-env#211)"
[smartwatermelon-private]="a private smartwatermelon repo is unprotected by need (decided 2026-10-12, dev-env#85)"
[twistedmelonman-private]="a GitHub Free user account cannot protect a private repo (decided 2026-10-21, dev-env#211)"
)
# Not exempt: private nightowlstudiollc repos. NOS keeps Team; protection
# there is required (decided 2026-10-15, dev-env#144).
exempt_class() {
local nwo="$1" fork="$2" private="$3"
if [[ "${fork}" == "true" ]]; then
echo fork
elif [[ "${private}" == "true" ]]; then
case "${nwo%%/*}" in
smartwatermelon) echo smartwatermelon-private ;;
twistedmelonman) echo twistedmelonman-private ;;
*) ;;
esac
fi
}

PROT_JSON=""
set_prot() {
# set_prot <state> <detail> [exempt class]
PROT_JSON="$(jq -cn --arg s "$1" --arg d "$2" --arg c "${3-}" \
'{state: $s, detail: (if $d == "" then null else $d end),
exemptClass: (if $c == "" then null else $c end)}' 2>/dev/null)"
if [[ -z "${PROT_JSON}" ]]; then
PROT_JSON='{"state":"unreadable","detail":"could not encode the protection result","exemptClass":null}'
fi
}

# Sets PROT_JSON for one repo. Never fails: a failed read is a state.
probe_protection() {
local nwo="$1" branch="$2" fork="$3" private="$4" body verdict state detail cls=""
if ! body="$(api "repos/${nwo}/branches/${branch}")"; then
detail="$(last_err "no detail")"
set_prot unreadable "cannot read branch ${branch}: ${detail}"
return 0
fi
verdict="$(jq -r "${prot_verdict}" <<<"${body}" 2>/dev/null)"
state="${verdict%%$'\t'*}"
detail="${verdict#*$'\t'}"
case "${state}" in
required | protected-no-standards | unreadable) ;;
unprotected)
# Exemption needs both flags; guessing would hide a real gap.
if [[ "${fork}" != "true" ]] \
&& [[ "${fork}" != "false" || ! "${private}" =~ ^(true|false)$ ]]; then
state=unreadable
detail="unprotected, but the repository listing gave no fork or private flag, so exemption cannot be decided"
else
cls="$(exempt_class "${nwo}" "${fork}" "${private}")"
if [[ -n "${cls}" ]]; then
state=exempt
# A class with no reason must not crash the survey under set -u.
detail="${EXEMPT_REASON[${cls}]-no reason recorded}"
fi
fi
;;
*)
state=unreadable
detail="the branch response is malformed"
;;
esac
set_prot "${state}" "${detail}" "${cls}"
}

survey_repo() {
local nwo="$1" branch="$2" head pulls pr pr_head run source ann count url
if ! head="$(api "repos/${nwo}/commits/${branch}" --jq '.sha')" \
Expand Down Expand Up @@ -204,7 +297,8 @@ if ! repos="$(api --paginate --slurp "installation/repositories?per_page=100")"
if type == "array" and all(.[]; (.repositories | type == "array"))
and ([.[].repositories[]] | length) >= (.[0].total_count // 0)
then [.[].repositories[]] | sort_by(.full_name)[]
| [.full_name, (.default_branch // ""), (.archived | tostring)] | @tsv
| [.full_name, (.archived | tostring), (.fork | tostring),
(.private | tostring), (.default_branch // "")] | @tsv
else error("bad shape") end' <<<"${repos}" 2>/dev/null)"; then
emit_err "" unlisted "cannot list the repositories this token can read" "unexpected response shape"
exit 0
Expand All @@ -214,13 +308,18 @@ if [[ -z "${repo_list}" ]]; then
exit 0
fi

while IFS=$'\t' read -r nwo branch archived; do
# The branch is last: tab is whitespace to read, so an empty field in the
# middle would shift the ones after it.
while IFS=$'\t' read -r nwo archived fork private branch; do
[[ -z "${nwo}" ]] && continue
PROT_JSON=""
if [[ "${archived}" == "true" ]]; then
emit "${nwo}" archived "archived; not surveyed"
elif [[ -z "${branch}" ]]; then
set_prot unreadable "no default branch reported"
emit "${nwo}" unreadable "no default branch reported"
else
probe_protection "${nwo}" "${branch}" "${fork}" "${private}"
survey_repo "${nwo}" "${branch}"
fi
done <<<"${repo_list}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[
{
"total_count": 5,
"repositories": [
{
"full_name": "nightowlstudiollc/tnjcleaning",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "nightowlstudiollc/cleanroom",
"default_branch": "main",
"archived": false,
"fork": false,
"private": true
},
{
"full_name": "nightowlstudiollc/nostd",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "nightowlstudiollc/offcheck",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "nightowlstudiollc/nos-private",
"default_branch": "main",
"archived": false,
"fork": false,
"private": true
}
]
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
[
{
"total_count": 6,
"repositories": [
{
"full_name": "smartwatermelon/dev-env",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "smartwatermelon/claude-config-backup",
"default_branch": "main",
"archived": false,
"fork": false,
"private": true
},
{
"full_name": "smartwatermelon/withheld",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "smartwatermelon/nullflag",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "smartwatermelon/noflags",
"default_branch": "main",
"archived": false
},
{
"full_name": "smartwatermelon/refused",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
}
]
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
{
"total_count": 4,
"repositories": [
{
"full_name": "twistedmelonman/Instapaper-MCP",
"default_branch": "main",
"archived": false,
"fork": true,
"private": false
},
{
"full_name": "twistedmelonman/tm-private",
"default_branch": "main",
"archived": false,
"fork": false,
"private": true
},
{
"full_name": "twistedmelonman/tm-public",
"default_branch": "main",
"archived": false,
"fork": false,
"private": false
},
{
"full_name": "twistedmelonman/tm-old",
"default_branch": "main",
"archived": true,
"fork": false,
"private": false
}
]
}
]
Loading
Loading