Skip to content

fix(sweep): repo_settings.yml aborts silently under GitHub's bash -e #293

Closed

Description

@Jammy2211

Overview

Regression in #292 (merge 475d292e0), filed the same day it shipped. GitHub runs a run: block as /usr/bin/bash -e {0}; repo_settings.yml sets set -uo pipefail and never disables -e, so the per-repo read aborts the whole script on the first unreadable repo — silently, because its stderr goes to /dev/null. The unreadable-repo handler, and with it the in-org/out-of-org failure-policy split #292 added, is dead code in production.

The weekly schedule fires Sunday 06:40, so this fails every week until fixed.

Plan

  • Disable -e explicitly in the block and comment why: the script's whole design is to survive a per-repo failure and report it, which GitHub's default flag defeats.
  • Make visit() never return non-zero spuriously — the [ ... ] && failed=1 trailing compound would abort on the first out-of-org repo.
  • Add the regression test that was missing: extract the workflow's run: block and assert the unreadable path under bash -e, matching GitHub's invocation.
  • Sibling sweep already done at filing: scope is one file (details below).
Detailed implementation plan

Affected Repositories

  • PyAutoBrain (primary)

Branch Survey

Repository Current Branch Dirty?
./PyAutoBrain main clean

Suggested branch: feature/repo-settings-bash-e-abort

The defect

before=$(gh api "repos/$slug" --jq '.delete_branch_on_merge' 2>/dev/null)

Non-zero for an unreadable repo → under -e the assignment aborts the script → 2>/dev/null makes it silent. The case "$before" in ... *) branch can never run. The PATCH path is fine (if gh api ...; then is -e-safe).

Latent second instance: [ "$hard" -eq 1 ] && failed=1 is the last statement of visit() in two branches; with hard=0 it returns 1, so the function returns 1 and -e aborts on the first out-of-org repo.

Evidence (confirmed, not inferred)

  • Run 32992618192 (mode=audit) failed in 20s with no stdout at all between ##[endgroup] and ##[error]Process completed with exit code 1. The log's env block prints shell: /usr/bin/bash -e {0}.
  • Reproduced locally by extracting the block and stubbing gh so one read fails: bash body.sh → ::warning:: + ::error::, exit 1 (graceful, as designed); bash -e body.sh → exit 1, zero output, byte-for-byte the CI signature.
  • Corollary: at least one repo read genuinely failed in that run. Which one is still unknown — a second finding hiding behind this one. The fix must surface it.

Why it shipped

#292 was control-tested across 7 scenarios before merge. The harness ran bash <script>; GitHub runs bash -e <script>. The harness did not match production, so it validated a shell mode that never executes.

Sibling sweep — done at filing

Every run: block in PyAutoBrain audited. branch_archive.yml and branch_sweep_all.yml explicitly set -e and use no 2>/dev/null — abort-on-error is intended there, so they are consistent, not defective. branch_sweep.yml, brain_board.yml, docs.yml, nightly-release.yml show no matching pattern. repo_settings.yml is the only block combining no e-flag statement with swallowed stderr. Scope is one file.

Implementation Steps

  1. .github/workflows/repo_settings.yml — set +e immediately after set -uo pipefail, commented with the reason. Preferred over || true on the read, because the abort risk applies to every guarded command in the block, not just that one.
  2. Same file — both [ "$hard" -eq 1 ] && failed=1 occurrences become if blocks.
  3. tests/test_repo_settings_workflow.py (new) — parse the workflow, extract the run: block, write a stub gh into a tmp dir on PATH, and run the block with bash -e. Assert: unreadable in-org repo → ::warning:: emitted and exit 1; unreadable out-of-org → warning and exit 0; all-on → exit 0. stdlib + PyYAML only, no network.

Key Files

  • .github/workflows/repo_settings.yml — the defect
  • tests/test_repo_settings_workflow.py — new; the missing bash -e leg

Acceptance

Re-dispatch repo_settings.yml in audit mode after merge. It must go green and name any repo whose read fails. A green run naming nothing is acceptable only if all 46 reads genuinely succeeded.

Original Prompt

Click to expand starting prompt

repo_settings.yml aborts silently under GitHub's bash -e

Type: bug
Target: pyautobrain
Repos:

  • PyAutoBrain
    Difficulty: small
    Autonomy: supervised
    Priority: high
    Status: formalised
    Filed: 2026-08-26

Regression in PyAutoBrain#292 (merge 475d292e0), filed the same day it shipped.
The weekly schedule fires Sunday 06:40, so it fails every week until fixed.

The bug

GitHub runs a run: block as /usr/bin/bash -e {0}. repo_settings.yml sets
set -uo pipefail and never disables -e. The per-repo read is:

before=$(gh api "repos/$slug" --jq '.delete_branch_on_merge' 2>/dev/null)

For an unreadable repo that assignment returns non-zero, so under -e the
script aborts instantly, and because stderr goes to /dev/null it aborts
silently. The case "$before" in ... *) unreadable handler is dead code in
production, and with it the whole in-org (error) vs out-of-org (warning)
failure-policy split that #292 was partly written to add.

Latent second instance: [ "$hard" -eq 1 ] && failed=1 is the last statement of
visit() in two branches. With hard=0 the compound returns 1, so the function
returns 1 — which under -e aborts on the first out-of-org repo.

The PATCH path is unaffected: if gh api ...; then is -e-safe.

Evidence (confirmed, not inferred)

  • Run https://github.com/PyAutoLabs/PyAutoBrain/actions/runs/32992618192
    (mode=audit) failed in 20s with no stdout at all between
    ##[endgroup] and ##[error]Process completed with exit code 1. The log's own
    env block prints shell: /usr/bin/bash -e {0}.
  • Reproduced locally: extract the run: block, stub gh so one repo read
    fails. bash body.sh prints the ::warning:: then ::error:: and exits 1
    (graceful, as designed). bash -e body.sh exits 1 with zero output —
    byte-for-byte the CI signature.

Corollary: at least one repo read genuinely failed in that run. The fixed
version must name it; we still do not know which repo, and that is a second
finding hiding behind this one.

Why it shipped — the actual lesson

#292 was control-tested, across 7 scenarios, before merge. The harness ran
bash <script>; GitHub runs bash -e <script>. The harness did not match
production
, so it validated a shell mode that never executes. A control test
that does not reproduce the caller's invocation proves nothing about the caller.

Sibling sweep — already done at filing

Audited every run: block in PyAutoBrain. branch_archive.yml and
branch_sweep_all.yml both explicitly set -e and use no 2>/dev/null;
abort-on-error is what they want, so they are consistent, not defective.
branch_sweep.yml, brain_board.yml, docs.yml, nightly-release.yml have no
matching pattern. repo_settings.yml is the only block with no e-flag
statement combined with a swallowed stderr. Scope is one file.

Fix

  1. set +e immediately after set -uo pipefail, commented with why — GitHub's
    default -e would abort on the first unreadable repo and skip this script's
    own per-repo reporting. Preferred over sprinkling || true, because the
    script's whole design is to survive per-repo failure and report it.
  2. Both [ "$hard" -eq 1 ] && failed=1 occurrences become if blocks, so
    visit() never returns non-zero spuriously.
  3. Regression-proof it: a test that extracts the workflow's run: block and
    asserts the unreadable-repo path emits its ::warning:: and the right exit
    code under bash -e, matching GitHub's invocation. This is the leg that
    was missing. stdlib + PyYAML only, no network — stub gh on PATH.

Acceptance

Re-dispatch repo_settings.yml in audit mode after merge. It must go green
and name any repo whose read fails. A green run that names nothing is only
acceptable if every one of the 46 reads genuinely succeeded.

Activity

  1. Jammy2211 commented on Aug 26, 2026

    @Jammy2211
    ContributorAuthor

    Shipped and verified — #294 merged (8b48a93bd)

    CI green on both legs at ceb425e0. Head auto-deleted on merge.

    Acceptance test passed, and it answered the open question. Re-dispatched repo_settings.yml in audit mode (run 32994175330) — green, and it named the repos the silence had been hiding:

    warning: Jammy2211/admin_jammy      — could not read the setting (repo missing, or the PAT cannot see it)
    warning: Jammy2211/euclid_assistant — could not read the setting (repo missing, or the PAT cannot see it)
    

    That is the whole failure explained end to end. The loop sweeps in-org repos first; in audit mode with all 44 already true it prints nothing, so the run got 20s in with no output, reached the first out-of-org repo, and -e killed it there. Both are personal-account repos that PAT_PYAUTOLABS cannot read — precisely the case the in-org/out-of-org split was written for, and precisely the case -e made invisible.

    Now-visible limitation, not a regression: those two repos are outside the sweep's reach. They are currently true (set by hand on 2026-08-26), but nothing maintains them. Either widen the PAT to cover them, or accept them as manually-managed — a human decision, and now at least a visible one rather than a silent abort.

    The lesson, recorded: #292 was control-tested across seven scenarios and still shipped this, because the harness ran bash <script> while GitHub runs bash -e <script>. tests/test_repo_settings_workflow.py now runs the extracted block the way the runner does, and pins that assumption with test_the_step_does_not_override_the_shell. The tests were verified to fail against the pre-fix workflow (3 failed / 3 passed) and pass against the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions