From fdba97bfb6d922e2a5a2a2db9d5ad80134e9d044 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 21 Oct 2026 08:40:56 -0700 Subject: [PATCH] feat(digest): survey default-branch protection in the daily digest dev-env#211 asked whether a periodic check should flag fleet repos that lack protection or a required standards-check. jordan-paddock-pitch went a week without either, and nothing noticed. The digest already enumerates every repo the App can read, so the check rides that loop instead of adding a second enumeration. standards.sh now reads GET repos/{nwo}/branches/{branch} for each non-archived repo and adds a "protection" object to its record. The probe runs before the standards survey, so a repo whose standards read fails still gets a protection result. States: - required: protected, enabled, enforcement not "off", and a required context matching the existing standards-check name rule. - protected-no-standards: protected, but standards-check is not required or nothing is enforced. .protected == true alone never counts as required: STATUS.md records (2026-09-16) that .protected read true for repos with no effective rules. - unprotected: .protected == false and no exempt class applies. - exempt: unprotected, and in a class exempt by decision. - unreadable: the read failed, .protected is not a boolean, the protection settings are missing or malformed, or the listing gave no fork/private flag for an unprotected repo. Whether the App token (contents/checks/statuses/pull-requests read, no administration) gets the protection object is unverified, so a withheld field must read as unreadable, never as a finding or as clean. Exempt classes are predicates, not repo names: forks, private smartwatermelon repos (dev-env#85), private twistedmelonman repos (Free user accounts cannot protect them). Private nightowlstudiollc repos are not exempt: NOS keeps Team and protection there is a requirement. Three predicates on fork, owner and visibility live in a commented block in standards.sh; a parsed config file would need its own mini-language. render.sh adds a "Branch protection" section: required and exempt repos are counted (exempt by class, with the reason); unprotected, protected-no-standards and unreadable repos are listed with links. If no repo could be read, the section says protection could not be verified and prints no counts. A record with no protection object is unreadable. Fixtures for required, exempt and unprotected states are real branch responses captured 2026-10-21, trimmed to name/protected/protection. The edge cases are those captures with one field changed. Each new assertion was checked against a deliberate break of the logic it covers. Closes #211 --- CLAUDE.md | 2 +- docs/STATUS.md | 4 + scripts/dependabot-digest/render.sh | 83 +++++++++++++ scripts/dependabot-digest/standards.sh | 111 +++++++++++++++++- ...lation_repositories.json.nightowlstudiollc | 42 +++++++ ...allation_repositories.json.smartwatermelon | 47 ++++++++ ...allation_repositories.json.twistedmelonman | 35 ++++++ ...towlstudiollc_cleanroom_branches_main.json | 21 ++++ ...wlstudiollc_nos-private_branches_main.json | 12 ++ ...nightowlstudiollc_nostd_branches_main.json | 17 +++ ...htowlstudiollc_offcheck_branches_main.json | 17 +++ ...wlstudiollc_tnjcleaning_branches_main.json | 17 +++ ...on_claude-config-backup_branches_main.json | 12 ++ ...smartwatermelon_dev-env_branches_main.json | 21 ++++ ...smartwatermelon_noflags_branches_main.json | 12 ++ ...martwatermelon_nullflag_branches_main.json | 21 ++++ ...martwatermelon_withheld_branches_main.json | 4 + ...melonman_Instapaper-MCP_branches_main.json | 12 ++ ...stedmelonman_tm-private_branches_main.json | 12 ++ ...istedmelonman_tm-public_branches_main.json | 12 ++ .../dependabot-digest/tests/test-render.sh | 12 ++ .../dependabot-digest/tests/test-standards.sh | 85 ++++++++++++++ 22 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.nightowlstudiollc create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.smartwatermelon create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.twistedmelonman create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_cleanroom_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nostd_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_offcheck_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_claude-config-backup_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_dev-env_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_noflags_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-private_branches_main.json create mode 100644 scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-public_branches_main.json diff --git a/CLAUDE.md b/CLAUDE.md index e1e58a0..0115233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/STATUS.md b/docs/STATUS.md index eb46645..8f771fa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/scripts/dependabot-digest/render.sh b/scripts/dependabot-digest/render.sh index ee4a1d2..b850232 100755 --- a/scripts/dependabot-digest/render.sh +++ b/scripts/dependabot-digest/render.sh @@ -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 "---" diff --git a/scripts/dependabot-digest/standards.sh b/scripts/dependabot-digest/standards.sh index 4592cc4..51bdd8f 100755 --- a/scripts/dependabot-digest/standards.sh +++ b/scripts/dependabot-digest/standards.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Usage: standards.sh . 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 @@ -34,6 +35,7 @@ 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), @@ -41,10 +43,10 @@ emit() { 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 @@ -121,6 +123,97 @@ run_on_commit() { jq -c "${pick_run} // empty" <<<"${body}" } +# dev-env#211: prints "statedetail". 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 [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')" \ @@ -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 @@ -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}" diff --git a/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.nightowlstudiollc b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.nightowlstudiollc new file mode 100644 index 0000000..a86affd --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.nightowlstudiollc @@ -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 + } + ] + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.smartwatermelon b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.smartwatermelon new file mode 100644 index 0000000..ffc3eb0 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.smartwatermelon @@ -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 + } + ] + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.twistedmelonman b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.twistedmelonman new file mode 100644 index 0000000..281e80b --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.twistedmelonman @@ -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 + } + ] + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_cleanroom_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_cleanroom_branches_main.json new file mode 100644 index 0000000..6b33887 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_cleanroom_branches_main.json @@ -0,0 +1,21 @@ +{ + "name": "main", + "protected": true, + "protection": { + "enabled": true, + "required_status_checks": { + "enforcement_level": "non_admins", + "contexts": ["standards-check / run-standards-check", "test"], + "checks": [ + { + "context": "standards-check / run-standards-check", + "app_id": null + }, + { + "context": "test", + "app_id": 15368 + } + ] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_branches_main.json new file mode 100644 index 0000000..c13c38a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_branches_main.json @@ -0,0 +1,12 @@ +{ + "name": "main", + "protected": false, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": [], + "checks": [] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nostd_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nostd_branches_main.json new file mode 100644 index 0000000..85100b5 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nostd_branches_main.json @@ -0,0 +1,17 @@ +{ + "name": "main", + "protected": true, + "protection": { + "enabled": true, + "required_status_checks": { + "enforcement_level": "non_admins", + "contexts": ["test"], + "checks": [ + { + "context": "test", + "app_id": 15368 + } + ] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_offcheck_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_offcheck_branches_main.json new file mode 100644 index 0000000..690529e --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_offcheck_branches_main.json @@ -0,0 +1,17 @@ +{ + "name": "main", + "protected": true, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": ["standards-check / run-standards-check"], + "checks": [ + { + "context": "standards-check / run-standards-check", + "app_id": 15368 + } + ] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_branches_main.json new file mode 100644 index 0000000..fd1af1c --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_branches_main.json @@ -0,0 +1,17 @@ +{ + "name": "main", + "protected": true, + "protection": { + "enabled": true, + "required_status_checks": { + "enforcement_level": "non_admins", + "contexts": ["standards-check / run-standards-check"], + "checks": [ + { + "context": "standards-check / run-standards-check", + "app_id": 15368 + } + ] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_claude-config-backup_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_claude-config-backup_branches_main.json new file mode 100644 index 0000000..c13c38a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_claude-config-backup_branches_main.json @@ -0,0 +1,12 @@ +{ + "name": "main", + "protected": false, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": [], + "checks": [] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_dev-env_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_dev-env_branches_main.json new file mode 100644 index 0000000..5ab79ef --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_dev-env_branches_main.json @@ -0,0 +1,21 @@ +{ + "name": "main", + "protected": true, + "protection": { + "enabled": true, + "required_status_checks": { + "enforcement_level": "non_admins", + "contexts": ["standards-check / run-standards-check", "tests"], + "checks": [ + { + "context": "standards-check / run-standards-check", + "app_id": 15368 + }, + { + "context": "tests", + "app_id": 15368 + } + ] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_noflags_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_noflags_branches_main.json new file mode 100644 index 0000000..c13c38a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_noflags_branches_main.json @@ -0,0 +1,12 @@ +{ + "name": "main", + "protected": false, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": [], + "checks": [] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_branches_main.json new file mode 100644 index 0000000..703cd3e --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_branches_main.json @@ -0,0 +1,21 @@ +{ + "name": "main", + "protected": null, + "protection": { + "enabled": true, + "required_status_checks": { + "enforcement_level": "non_admins", + "contexts": ["standards-check / run-standards-check", "tests"], + "checks": [ + { + "context": "standards-check / run-standards-check", + "app_id": 15368 + }, + { + "context": "tests", + "app_id": 15368 + } + ] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_branches_main.json new file mode 100644 index 0000000..a5900ab --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_branches_main.json @@ -0,0 +1,4 @@ +{ + "name": "main", + "protected": true +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_branches_main.json new file mode 100644 index 0000000..c13c38a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_branches_main.json @@ -0,0 +1,12 @@ +{ + "name": "main", + "protected": false, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": [], + "checks": [] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-private_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-private_branches_main.json new file mode 100644 index 0000000..c13c38a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-private_branches_main.json @@ -0,0 +1,12 @@ +{ + "name": "main", + "protected": false, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": [], + "checks": [] + } + } +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-public_branches_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-public_branches_main.json new file mode 100644 index 0000000..c13c38a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_tm-public_branches_main.json @@ -0,0 +1,12 @@ +{ + "name": "main", + "protected": false, + "protection": { + "enabled": false, + "required_status_checks": { + "enforcement_level": "off", + "contexts": [], + "checks": [] + } + } +} diff --git a/scripts/dependabot-digest/tests/test-render.sh b/scripts/dependabot-digest/tests/test-render.sh index c8c7c82..bb4745a 100755 --- a/scripts/dependabot-digest/tests/test-render.sh +++ b/scripts/dependabot-digest/tests/test-render.sh @@ -132,6 +132,18 @@ else _fail "an empty queue did not render correctly" fi +# dev-env#211: no survey, no protection section; a malformed one must not +# read clean. +_hasnt '## Branch protection' "no survey, no protection section" +printf '%s\n' '{not json' >"${WORK}/bad.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/bad.ndjson" >"${WORK}/bad.md" 2>/dev/null +prot_section="$(sed -n '/^## Branch protection/,/^---/p' "${WORK}/bad.md")" +if [[ "${prot_section}" == *"**Not checked.**"* && "${prot_section}" != *"No gaps"* ]]; then + _pass "a malformed survey renders protection as not checked" +else + _fail "a malformed survey did not render protection as not checked: ${prot_section}" +fi + if [[ "${fail}" -eq 0 ]]; then echo "test-render: all assertions passed" else diff --git a/scripts/dependabot-digest/tests/test-standards.sh b/scripts/dependabot-digest/tests/test-standards.sh index 5563232..6aaeb04 100755 --- a/scripts/dependabot-digest/tests/test-standards.sh +++ b/scripts/dependabot-digest/tests/test-standards.sh @@ -196,6 +196,91 @@ _hasnt 'No warnings' "a malformed survey never claims no warnings" "${WORK}/bad. : | bash "${DIR}/render.sh" >"${WORK}/none.md" 2>/dev/null _hasnt '## Standards warnings' "no survey, no section" "${WORK}/none.md" +# dev-env#211: real owner names, since exempt classes key on them. Branch +# fixtures: real 2026-10-21 captures, some with one field changed. +prot="${WORK}/prot.ndjson" +: >"${prot}" +for o in smartwatermelon nightowlstudiollc twistedmelonman; do + if ! survey "${o}" >>"${prot}" 2>"${WORK}/${o}.err"; then + _fail "standards.sh exited non-zero for owner ${o}" + fi +done +_prot() { + local got + got="$(jq -r --arg r "$1" 'select(.repo == $r) | (.protection.state // "none") + " " + (.protection.exemptClass // "-")' "${prot}")" + if [[ "${got}" == "$2" ]]; then _pass "$3"; else _fail "$3 (protection: '${got}', want '$2')"; fi +} +_prot smartwatermelon/dev-env "required -" "a real protected repo requiring standards-check is required" +_prot nightowlstudiollc/tnjcleaning "required -" "a real repo requiring only standards-check is required" +_prot nightowlstudiollc/cleanroom "required -" "a check with a null app_id still counts as required" +_prot nightowlstudiollc/nostd "protected-no-standards -" "a protected repo requiring other checks is flagged" +_prot nightowlstudiollc/offcheck "protected-no-standards -" "standards-check listed but enforcement off is not required" +_prot smartwatermelon/claude-config-backup "exempt smartwatermelon-private" \ + "a private unprotected smartwatermelon repo is exempt" +_prot twistedmelonman/Instapaper-MCP "exempt fork" "an unprotected fork is exempt" +_prot twistedmelonman/tm-private "exempt twistedmelonman-private" \ + "a private unprotected twistedmelonman repo is exempt" +_prot nightowlstudiollc/nos-private "unprotected -" \ + "a private unprotected nightowlstudiollc repo is unprotected, not exempt" +_prot twistedmelonman/tm-public "unprotected -" "a public unprotected repo is unprotected" +_prot smartwatermelon/withheld "unreadable -" \ + "protected with the protection object withheld is unreadable, not fine" +_prot smartwatermelon/nullflag "unreadable -" "a non-boolean protected flag is unreadable" +_prot smartwatermelon/noflags "unreadable -" \ + "unprotected with no fork or private flag is unreadable, not unprotected or exempt" +_prot smartwatermelon/refused "unreadable -" "a refused branch read is unreadable, not unprotected" +_prot twistedmelonman/tm-old "none -" "an archived repo has no protection result" +if jq -e 'select(.repo == "smartwatermelon/refused") | .protection.detail | test("HTTP 403")' "${prot}" >/dev/null 2>&1; then + _pass "the reason a branch read failed is kept" +else + _fail "the refused branch read lost its reason" +fi +# The protection probe must not depend on the standards survey: these repos +# have no commit fixtures, so their standards state is unreadable. +dev_env="$(jq -r 'select(.repo == "smartwatermelon/dev-env") | .state + " " + .protection.state' "${prot}")" +if [[ "${dev_env}" == "unreadable required" ]]; then + _pass "a repo whose standards read failed still gets a protection result" +else + _fail "the protection result depends on the standards read (got '${dev_env}')" +fi + +pbody="${WORK}/prot.md" +: | bash "${DIR}/render.sh" --standards "${prot}" >"${pbody}" 2>/dev/null +_has '## Branch protection' "the protection section is rendered" "${pbody}" +_has '**14 repositories surveyed**: 3 require standards-check, 3 exempt, **4 need attention, 4 unreadable.**' \ + "the protection summary counts each state" "${pbody}" +_has '- fork: 1 — a fork is not ours to govern (decided 2026-10-21, dev-env#211)' "exempt repos are counted by class with the reason" "${pbody}" +_hasnt '[twistedmelonman/Instapaper-MCP]' "exempt repos are not listed" "${pbody}" +_hasnt '[smartwatermelon/dev-env]' "required repos are not listed" "${pbody}" +_has '| [nightowlstudiollc/nos-private](https://github.com/nightowlstudiollc/nos-private) | UNPROTECTED: the default branch has no protection |' \ + "an unprotected repo is listed with a link" "${pbody}" +_has '| [nightowlstudiollc/nostd](https://github.com/nightowlstudiollc/nostd) | standards-check not required: requires only: test |' \ + "a protected repo without standards-check is listed with its checks" "${pbody}" +_has '| [smartwatermelon/withheld](https://github.com/smartwatermelon/withheld) | UNREADABLE:' \ + "an unreadable repo is listed as unreadable" "${pbody}" +_hasnt 'No gaps' "a survey with gaps does not claim none" "${pbody}" + +# Every protection read failed (the o and p fixtures have no branch +# responses): an under-scoped token must not render as a clean fleet. +_has '**Protection could not be verified for any of 8 repositories.**' \ + "an all-unreadable protection survey says it verified nothing" "${body}" +_hasnt 'need attention' "an all-unreadable protection survey prints no counts" "${body}" +_hasnt 'No gaps' "an all-unreadable protection survey never claims no gaps" "${body}" +_has '**Not listed, so not surveyed:** q.' "an unlistable owner is named in the protection section" "${body}" + +# All clean: an explicit statement, and no table. +jq -c 'select(.repo == "smartwatermelon/dev-env" or .repo == "twistedmelonman/Instapaper-MCP")' "${prot}" >"${WORK}/pclean.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/pclean.ndjson" >"${WORK}/pclean.md" 2>/dev/null +_has 'No gaps: every surveyed repository' "a fully protected survey says so" "${WORK}/pclean.md" +_hasnt '| Repo | Protection |' "a fully protected survey has no table" "${WORK}/pclean.md" + +# A record with no protection field (a crashed survey) is unreadable. +printf '%s\n' '{"owner":"o","repo":"o/legacy","state":"ok","annotations":[]}' >"${WORK}/legacy.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/legacy.ndjson" >"${WORK}/legacy.md" 2>/dev/null +_has '| [o/legacy](https://github.com/o/legacy) | UNREADABLE: no protection result recorded |' \ + "a record without a protection field is unreadable, not clean" "${WORK}/legacy.md" +_hasnt 'No gaps' "a record without a protection field never reads as no gaps" "${WORK}/legacy.md" + if [[ "${fail}" -eq 0 ]]; then echo "test-standards: all assertions passed" else