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
190 changes: 138 additions & 52 deletions .github/workflows/void.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ name: Gut Void
# SHA with `git ls-remote` (absent → skipped with a note), then deletes
# refs/heads/archive/condemned/<name> 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.
#
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |"
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand All @@ -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**."
Expand All @@ -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
Expand All @@ -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; }
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Repo> 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).
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Repo> 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;
Expand Down
9 changes: 5 additions & 4 deletions scripts/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
lives on a sibling repo's origin (``… on <Repo> 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;
Expand All @@ -34,7 +34,8 @@
**The button is a prefilled GitHub issue.** "Void permanently" opens
``issues/new?title=void:+<name>&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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading