From e8037b5fe270c09afa269d4ecac0224aa048e557 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 23 Oct 2026 16:09:43 +0100 Subject: [PATCH] release: void sibling refs with the pyautolabs-bot App, no extraheader plumbing (token phase 2b) void.yml mints an owner-wide App token (contents write) for sibling-repo deletes; git reaches it through `gh auth setup-git` from an empty scratch repo. The GIT_CONFIG_KEY_0 extraheader plumbing, its base64/mask handling, the global-extraheader guard and the "skipped: no PAT" fallback are removed: a mint failure fails the run. Own-repo deletes stay on github.token. New workflow_dispatch `dry_run` input runs only a `probe` job: same plan, same token, ls-remote + push --dry-run --delete per due sibling ref; deletes, comments and closes nothing. Board text, tests, README and AGENTS updated. Refs PyAutoLabs/PyAutoBrain#534 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/void.yml | 190 ++++++++++++++++++++++++++---------- AGENTS.md | 18 ++-- README.md | 17 ++-- scripts/board.py | 9 +- tests/test_board.py | 2 +- tests/test_void_workflow.py | 61 ++++++++---- 6 files changed, 205 insertions(+), 92 deletions(-) diff --git a/.github/workflows/void.yml b/.github/workflows/void.yml index e6142f13..d45a4b94 100644 --- a/.github/workflows/void.yml +++ b/.github/workflows/void.yml @@ -15,10 +15,9 @@ name: Gut Void # SHA with `git ls-remote` (absent → skipped with a note), then deletes # refs/heads/archive/condemned/ there, then re-checks it is gone. # In the Gut: `git push origin --delete` with this repo's GITHUB_TOKEN. -# On a sibling: the org PAT (secrets.PAT_PYAUTOLABS, the token Brain's -# branch_archive.yml pushes cross-repo with), from an empty scratch repo, -# sent as an HTTP header through GIT_CONFIG_* env — never in a URL, an -# argv or a log line, and masked with ::add-mask:: besides; +# On a sibling: a one-hour `pyautolabs-bot` App token (contents write, +# every installed repo), minted in this job and handed to git through +# `gh auth setup-git`, from an empty scratch repo — never in a URL; # 4. comments a table (repo | name | pre-delete sha | result), closes the # issue as completed and re-dispatches the Gut Board. # @@ -28,11 +27,17 @@ name: Gut Void # human request), in the Gut or on a sibling. `void: all-due` takes the due # set only: never undated entries, never refs without a ledger entry. # -# Sibling reach depends on the PAT: no PAT → each sibling ref is reported -# "skipped: no PAT" and left in place; a 403 (a repo born after the PAT was -# minted may be missing from its repository list) → a per-ref failure row -# naming the repo. Either way the issue is LEFT OPEN — it closes as completed -# only when every requested ref was voided or absent (skipped by design). +# Sibling reach: a token that cannot be minted fails the job (the issue is +# commented and left open); a sibling that refuses the App token (403) → a +# per-ref failure row naming the repo, and the issue is LEFT OPEN — it closes +# as completed only when every requested ref was voided or absent (skipped by +# design). +# +# `workflow_dispatch` with `dry_run: true` runs only the `probe` job: it +# computes the same plan, mints the same token and runs `git ls-remote` + +# `git push --dry-run --delete` for every due ref (no pack is sent, no ref +# moves) — proof of the credential that deletes nothing, comments nothing and +# closes nothing. # # The label is a hint, not a gate: GitHub's issue form can drop `labels=` on # submission (seen on the Memory queue from phones), so the `void:` title @@ -46,6 +51,12 @@ name: Gut Void on: issues: types: [opened] + workflow_dispatch: + inputs: + dry_run: + description: "Prove the void credential: push --dry-run --delete every due ref, change nothing" + type: boolean + default: false # contents: write → delete the ref; issues: write → label, comment, close; # actions: write → re-dispatch gut_board.yml (token pushes/closes do not @@ -180,38 +191,40 @@ jobs: echo "target=$target" >> "$GITHUB_OUTPUT" echo "count=$(wc -l < checked.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" + # Owner-wide (no `repositories:`): a void can name a ref held on any + # sibling repo, and the App is installed on every repo, so a repo born + # tomorrow is reachable with no token edit. contents: write is the + # delete; nothing else. + - name: Mint App token (every installed repo — delete sibling refs) + id: app_token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PyAutoLabs + permission-contents: write + - name: Void each ref (ls-remote → push --delete → verify) id: void env: - # Only this step sees the org PAT; it is used for sibling repos only. - PAT: ${{ secrets.PAT_PYAUTOLABS }} + # Only this step sees the App token; it is used for sibling repos only. + APP_TOKEN: ${{ steps.app_token.outputs.token }} run: | own="${GITHUB_REPOSITORY#*/}" owner="${GITHUB_REPOSITORY%%/*}" fail=0 - : > pat_failed.txt - # Sibling repos: the PAT goes out as an HTTP Authorization header - # through GIT_CONFIG_* environment entries (not argv, not a URL), - # from an empty scratch repo — the checkout's persisted - # GITHUB_TOKEN extraheader lives in ITS .git/config and would - # otherwise ride along (and win) on every github.com request. - sib="" - if [ -n "${PAT:-}" ]; then - echo "::add-mask::$PAT" - auth=$(printf 'x-access-token:%s' "$PAT" | base64 | tr -d '\n') - echo "::add-mask::$auth" - if git config --global --get-regexp 'http\..*extraheader' >/dev/null 2>&1; then - echo "::error::a global git extraheader is set; it would override the PAT." - exit 1 - fi - sib=$(mktemp -d) - git -C "$sib" init -q - fi + : > token_failed.txt + # Sibling repos: gh is git's credential helper (`gh auth setup-git`) + # and reads the App token from GH_TOKEN, set per call below. They + # run from an empty scratch repo, so the checkout's persisted + # GITHUB_TOKEN header (local to ITS .git/config) never rides along; + # in the Gut itself that header is exactly what deletes, with this + # repo's own token. + GH_TOKEN="$APP_TOKEN" gh auth setup-git + sib=$(mktemp -d) + git -C "$sib" init -q sibling_git() { - GIT_TERMINAL_PROMPT=0 GIT_CONFIG_COUNT=1 \ - GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth" \ - git -C "$sib" "$@" + GH_TOKEN="$APP_TOKEN" GIT_TERMINAL_PROMPT=0 git -C "$sib" "$@" } { echo "| repo | name | pre-delete sha | result |" @@ -240,17 +253,12 @@ jobs: fi continue fi - # A ref held on a sibling repo: the org PAT or nothing. + # A ref held on a sibling repo: the App token. url="https://github.com/$owner/$repo.git" - if [ -z "$sib" ]; then - echo "| $repo | \`$name\` | — | **skipped: no PAT** (PAT_PYAUTOLABS is not set) — left in place |" >> table.md - fail=1 - continue - fi if ! listing=$(sibling_git ls-remote "$url" "$ref" 2>ls.err); then - echo "| $repo | \`$name\` | — | **failed: could not read $repo with the PAT** — left in place |" >> table.md + echo "| $repo | \`$name\` | — | **failed: could not read $repo with the App token** — left in place |" >> table.md sed 's/^/ /' ls.err || true - echo "$repo" >> pat_failed.txt + echo "$repo" >> token_failed.txt fail=1 continue fi @@ -274,15 +282,15 @@ jobs: fi else reason="delete failed" - grep -q '403' push.err && reason="delete failed: 403 — the PAT cannot push to $repo" + grep -q '403' push.err && reason="delete failed: 403 — the App token cannot push to $repo" echo "| $repo | \`$name\` | \`$sha\` | **$reason** — left in place |" >> table.md sed 's/^/ /' push.err || true - echo "$repo" >> pat_failed.txt + echo "$repo" >> token_failed.txt fail=1 fi done < checked.txt - [ -z "$sib" ] || rm -rf "$sib" - sort -u pat_failed.txt -o pat_failed.txt + rm -rf "$sib" + sort -u token_failed.txt -o token_failed.txt echo "fail=$fail" >> "$GITHUB_OUTPUT" cat table.md >> "$GITHUB_STEP_SUMMARY" @@ -299,12 +307,8 @@ jobs: if [ "$COUNT" != "0" ]; then cat table.md echo - if [ -s pat_failed.txt ]; then - echo "The org PAT (PAT_PYAUTOLABS) could not act on: $(paste -sd, pat_failed.txt | sed 's/,/, /g'). A repo born after the PAT was minted may be missing from its repository list — add it there, then submit a new void issue. Those refs were left in place." - echo - fi - if grep -q 'skipped: no PAT' table.md; then - echo "PAT_PYAUTOLABS is not set on this repo, so refs held on sibling repos were left in place." + if [ -s token_failed.txt ]; then + echo "The pyautolabs-bot App token could not act on: $(paste -sd, token_failed.txt | sed 's/,/, /g'). The App is installed on every repo, so check the run log for that repo's refusal (installation or branch rules), then submit a new void issue. Those refs were left in place." echo fi echo "Voided refs are gone for good — **recovery is no longer possible**." @@ -315,7 +319,7 @@ jobs: } > comment.md gh issue comment "$ISSUE" --repo "$GITHUB_REPOSITORY" --body-file comment.md if [ "$FAIL" = "1" ]; then - echo "::error::at least one requested ref was not voided (failed, or skipped for want of the PAT) — issue left open" + echo "::error::at least one requested ref was not voided — issue left open" exit 1 fi if [ -s refused.md ] && [ "$COUNT" = "0" ]; then @@ -331,3 +335,85 @@ jobs: run: | gh issue comment "$ISSUE" --repo "$GITHUB_REPOSITORY" --body \ "The Gut void run failed ($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID). The issue is left open; check the run log before retrying." || true + + probe: + name: Prove the void credential (dry run) + if: github.event_name == 'workflow_dispatch' && inputs.dry_run + runs-on: ubuntu-latest + permissions: + contents: read + env: + NS: refs/heads/archive/condemned + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoBrain + path: PyAutoBrain + + - uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoMind + path: PyAutoMind + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Compute the void plan from ls-remote + condemned.md + run: | + python scripts/board.py --mind PyAutoMind --brain PyAutoBrain \ + --collect snap.json + python scripts/board.py --snapshot snap.json --void-plan > plan.json + jq -r '.due[] | [.repo, .name] | @tsv' plan.json > due.txt + cat due.txt + + - name: Mint App token (every installed repo — delete sibling refs) + id: app_token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PyAutoLabs + permission-contents: write + + - name: ls-remote + push --dry-run --delete each due sibling ref + env: + APP_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + own="${GITHUB_REPOSITORY#*/}" + owner="${GITHUB_REPOSITORY%%/*}" + GH_TOKEN="$APP_TOKEN" gh auth setup-git + sib=$(mktemp -d) + git -C "$sib" init -q + fail=0 + probed=0 + { + echo "## Gut void — dry run (nothing deleted)" + echo + echo "| repo | name | ls-remote | push --dry-run --delete |" + echo "|---|---|---|---|" + } >> "$GITHUB_STEP_SUMMARY" + while IFS=$'\t' read -r repo name; do + [ -n "$name" ] && [ "$repo" != "$own" ] || continue + url="https://github.com/$owner/$repo.git" + ref="$NS/$name" + if ! sha=$(GH_TOKEN="$APP_TOKEN" git -C "$sib" ls-remote "$url" "$ref" | awk '{print $1}') || [ -z "$sha" ]; then + echo "| $repo | \`$name\` | **unreadable or absent** | — |" >> "$GITHUB_STEP_SUMMARY" + fail=1; continue + fi + if GH_TOKEN="$APP_TOKEN" GIT_TERMINAL_PROMPT=0 git -C "$sib" push --dry-run "$url" --delete "$ref"; then + echo "| $repo | \`$name\` | \`$sha\` | ok |" >> "$GITHUB_STEP_SUMMARY" + else + echo "| $repo | \`$name\` | \`$sha\` | **refused** |" >> "$GITHUB_STEP_SUMMARY" + fail=1 + fi + probed=$((probed + 1)) + done < due.txt + rm -rf "$sib" + echo "probed $probed sibling ref(s)" + if [ "$probed" = "0" ]; then + echo "::warning::no due sibling refs to probe — the credential was minted but not exercised" + fi + [ "$fail" = "0" ] || { echo "::error::the App token could not reach at least one sibling ref"; exit 1; } diff --git a/AGENTS.md b/AGENTS.md index 5103b824..bb48cdf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,17 +119,17 @@ The Gut publishes a board (`scripts/board.py` → `gut_board.yml` → Pages + the Gut or on a sibling repo — are refused by the workflow and left out of `all-due`; orphans (no ledger entry) are single-void only, never in `all-due`. -- **The reach covers sibling repos (PyAutoGut#11), through the org PAT.** An +- **The reach covers sibling repos (PyAutoGut#11), through the org App.** An entry whose `archive-ref` says `… on origin` is voided on that repo: `--void-plan` carries `{name, repo, sha}` per entry, and void.yml deletes - a sibling ref with `secrets.PAT_PYAUTOLABS` (the Gut's own token cannot - reach another repo). The PAT is sent as an `http.extraheader` via - `GIT_CONFIG_*` env from an empty scratch repo — never in a URL, argv or log - — and masked with `::add-mask::`. No PAT → "skipped: no PAT"; a 403 (the - PAT403 precedent: a newborn repo missing from the PAT's repository list) → - a per-ref failure row naming the repo. Either way the ref stays and the - issue stays open; it closes as completed only when every requested ref was - voided or absent. + a sibling ref with a one-hour `pyautolabs-bot` App token (owner-wide, + contents write; the Gut's own token cannot reach another repo), handed to + git by `gh auth setup-git` from an empty scratch repo — never in a URL. A + mint failure fails the run; a 403 from a sibling → a per-ref failure row + naming the repo, the ref stays and the issue stays open; it closes as + completed only when every requested ref was voided or absent. The + dispatch-only `dry_run` probe `push --dry-run --delete`s every due sibling + ref to prove the credential and changes nothing. - **Ledger retirement is a session act.** Neither token edits the Mind; a voided ref's entry is retired from `condemned.md` by a session (the board's "retire entry" payload). diff --git a/README.md b/README.md index 346a1f48..8525ad0f 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,16 @@ then deletes that ref, comments the repo and pre-delete SHA, closes the issue and re-renders the board. **Void all due** does the same for every due row (`void: all-due`). The button reaches refs **held on another repo** too (an entry whose `archive-ref` says `… on origin`): refs in the Gut are -deleted with this repo's own token, refs on a sibling repo with the org PAT -`secrets.PAT_PYAUTOLABS` — the token Brain's `branch_archive.yml` already -pushes cross-repo with. The PAT is sent as an HTTP header from an empty scratch -repo (never in a URL or a log line, and masked). Without the PAT, or when the PAT -cannot reach a repo (a repo born after the PAT was minted may be missing from -its repository list), that ref is reported per row in the closing comment — -which names the repo — and left in place, and the issue stays open. The contract: +deleted with this repo's own token, refs on a sibling repo with a one-hour +token minted from the org's `pyautolabs-bot` GitHub App (contents write on +every installed repo, so a new repo needs no token change; the same App Brain's +`branch_archive.yml` pushes into the Gut with). git reaches it through +`gh auth setup-git` from an empty scratch repo — never a credential in a URL. +A token that cannot be minted fails the run; a sibling that refuses it is +reported per row in the closing comment — which names the repo — and left in +place, and the issue stays open. Dispatching `void.yml` with `dry_run: true` +runs `git push --dry-run --delete` for every due sibling ref and deletes +nothing. The contract: - only owners, members and collaborators can void — anyone else's issue is closed untouched; diff --git a/scripts/board.py b/scripts/board.py index 91cd536f..ca6351d3 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -25,7 +25,7 @@ lives on a sibling repo's origin (``… on origin``): read with a best-effort ``ls-remote`` of that repo, flagged when due, and voidable with the same button when dated and present — the void workflow deletes it on - the sibling with the org PAT (``PAT_PYAUTOLABS``); undated sibling refs are + the sibling with the org's ``pyautolabs-bot`` App token; undated sibling refs are held like any other; * **history-only** — ``archive-ref: n/a`` (bytes live in remote history): listed for completeness, never voidable; @@ -34,7 +34,8 @@ **The button is a prefilled GitHub issue.** "Void permanently" opens ``issues/new?title=void:+&labels=void&body=…``; pressing Submit is the human's ``--yes``. ``.github/workflows/void.yml`` deletes the ref — with this -repo's own token when the ref is in the Gut, with ``PAT_PYAUTOLABS`` when it +repo's own token when the ref is in the Gut, with a ``pyautolabs-bot`` App +token when it is held on a sibling repo — comments the repo and pre-delete SHA, closes the issue and re-renders this board. ``void: all-due`` voids every *due* row at once, in the Gut and on sibling repos alike. @@ -254,7 +255,7 @@ def void_issue_url(snap: dict, row: dict) -> str: return "" where = row_repo(snap, row) held_on = (f" on {where} (held on a sibling repo — deleted there with " - "the org PAT, PAT_PYAUTOLABS)" if row.get("host") else + "the pyautolabs-bot App token)" if row.get("host") else f" on {where}") body = (f"Void `{NS}{row['name']}` permanently{held_on}.\n\n" f"- repo: {where}\n" @@ -294,7 +295,7 @@ def void_all_due_url(snap: dict) -> str: f"{listing}\n\nThe workflow recomputes the due set from " "condemned.md when it runs; undated (held) entries and refs with " "no ledger entry are never included. Refs held on sibling repos " - "are deleted there with the org PAT (PAT_PYAUTOLABS).\n\n" + "are deleted there with the pyautolabs-bot App token.\n\n" "Submitting this issue " "voids the refs permanently. Recovery is no longer possible " "afterwards.") diff --git a/tests/test_board.py b/tests/test_board.py index b8e46679..a9eb227a 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -178,7 +178,7 @@ def test_sibling_held_rows_get_the_button_naming_the_repo(parser): assert q["title"] == ["void: sibling-stash-0"] body = q["body"][0] assert "- repo: SomeSibling" in body and "on SomeSibling" in body - assert "PAT_PYAUTOLABS" in body and "6" * 40 in body + assert "pyautolabs-bot App token" in body and "6" * 40 in body # not listed this render → no SHA → no button unlisted = _snap(parser, foreign={"SomeSibling": None}) assert not unlisted["buckets"]["elsewhere"][0]["void_url"] diff --git a/tests/test_void_workflow.py b/tests/test_void_workflow.py index ec90b52f..aee14327 100644 --- a/tests/test_void_workflow.py +++ b/tests/test_void_workflow.py @@ -2,8 +2,8 @@ its safety gates — the author-association gate, the `void:` title prefix (labels can be dropped by the issue form), the namespace refusal, the held refusal and `push origin --delete` behind an ls-remote read — plus the -sibling-repo reach: the PAT branch, its mask, the repo column and the per-ref -failure rows.""" +sibling-repo reach: the App-token branch, the repo column and the per-ref +failure rows, and the dispatch-only dry-run probe.""" from pathlib import Path @@ -24,7 +24,11 @@ def _on(doc): def test_void_workflow_triggers_and_permissions(): text, doc = _load("void.yml") assert doc["name"] == "Gut Void" - assert _on(doc) == {"issues": {"types": ["opened"]}} + on = _on(doc) + assert on["issues"] == {"types": ["opened"]} + # dispatch exists only for the dry-run probe; it never reaches `void` + assert on["workflow_dispatch"]["inputs"]["dry_run"]["default"] is False + assert "github.event.issue.title" in doc["jobs"]["void"]["if"] assert doc["permissions"] == {"contents": "write", "issues": "write", "actions": "write"} @@ -91,31 +95,50 @@ def test_board_workflow_publishes_and_validates_the_feed(): assert "enablement: true" in text -def test_void_workflow_reaches_sibling_repos_with_the_pat_kept_out_of_logs(): +def test_void_workflow_reaches_sibling_repos_with_the_app_token(): text, doc = _load("void.yml") steps = {s.get("name"): s for s in doc["jobs"]["void"]["steps"]} + mint = steps["Mint App token (every installed repo — delete sibling refs)"] + assert mint["uses"] == "actions/create-github-app-token@v3" + assert mint["with"]["client-id"] == "${{ vars.APP_CLIENT_ID }}" + assert mint["with"]["private-key"] == "${{ secrets.APP_PRIVATE_KEY }}" + assert mint["with"]["permission-contents"] == "write" + assert "repositories" not in mint["with"] # owner-wide: any sibling void = steps["Void each ref (ls-remote → push --delete → verify)"] - # the PAT is scoped to the one step that needs it - assert void["env"] == {"PAT": "${{ secrets.PAT_PYAUTOLABS }}"} - assert sum("secrets.PAT_PYAUTOLABS" in str(s) for s in steps.values()) == 1 + # the App token is scoped to the one step that needs it + assert void["env"] == {"APP_TOKEN": "${{ steps.app_token.outputs.token }}"} + assert sum("steps.app_token.outputs.token" in str(s) + for s in steps.values()) == 1 run = void["run"] assert 'if [ "$repo" = "$own" ]; then' in run - # masked (raw and header form) and sent as a header via env, never a URL - assert 'echo "::add-mask::$PAT"' in run - assert 'echo "::add-mask::$auth"' in run - assert "GIT_CONFIG_KEY_0=\"http.https://github.com/.extraheader\"" in run - assert "x-access-token:${PAT}@" not in text and "x-access-token:$PAT@" not in text + # gh is git's credential helper; no credential in a URL or a header env + assert 'GH_TOKEN="$APP_TOKEN" gh auth setup-git' in run + assert "PAT_PYAUTOLABS" not in text and "x-access-token" not in text + assert "GIT_CONFIG_KEY" not in text and "extraheader" not in run + assert "|| github.token" not in text assert 'url="https://github.com/$owner/$repo.git"' in run # ls-remote → push --delete → verify on the sibling, in that order i_ls = run.index('sibling_git ls-remote "$url" "$ref" 2>ls.err') i_push = run.index('sibling_git push "$url" --delete "$ref"') i_verify = run.index('left=$(sibling_git ls-remote "$url" "$ref"') assert i_ls < i_push < i_verify - # per-ref outcomes: no PAT, unreadable, 403, generic failure - assert "skipped: no PAT" in run - assert "could not read $repo with the PAT" in run - assert "403 — the PAT cannot push to $repo" in run - assert "left in place" in run and 'echo "$repo" >> pat_failed.txt' in run + # per-ref outcomes: unreadable, 403, generic failure + assert "could not read $repo with the App token" in run + assert "403 — the App token cannot push to $repo" in run + assert "left in place" in run and 'echo "$repo" >> token_failed.txt' in run + + +def test_void_dry_run_probe_deletes_nothing(): + text, doc = _load("void.yml") + probe = doc["jobs"]["probe"] + assert probe["if"] == "github.event_name == 'workflow_dispatch' && inputs.dry_run" + assert probe["permissions"] == {"contents": "read"} + body = "\n".join(str(s.get("run", "")) for s in probe["steps"]) + assert "push --dry-run" in body and "ls-remote" in body + for forbidden in ("gh issue", "gh workflow run", "push origin --delete"): + assert forbidden not in body + import re + assert not re.search(r"git -C \"\$sib\" push (?!--dry-run)", body) def test_void_comment_has_a_repo_column_and_names_the_pat_gaps(): @@ -124,6 +147,6 @@ def test_void_comment_has_a_repo_column_and_names_the_pat_gaps(): assert "|---|---|---|---|" in text report = next(s for s in doc["jobs"]["void"]["steps"] if s.get("name") == "Report, close and re-render")["run"] - assert "pat_failed.txt" in report and "missing from its repository list" in report - # a failure or a no-PAT skip leaves the issue open (exit before close) + assert "token_failed.txt" in report and "pyautolabs-bot App token" in report + # a failure leaves the issue open (exit before close) assert report.index('if [ "$FAIL" = "1" ]') < report.index("--reason completed")