Bulk Manage GitHub Actions State
Python script to scan all personal repos, bulk disable GitHub Actions, and re-enable them later using a saved state file.
Originally created in response to GHSA-f9f8-rm49-7jv2 — a Composer vulnerability (patched in 1.10.28 / 2.2.28 / 2.9.8) where GITHUB_TOKEN is leaked in plaintext to Actions logs via exception messages. Disabling Actions across all repos was the fastest way to stop token exposure while upgrading Composer across affected workflows.
Useful any time you want to temporarily pause all CI runs (incident response, cost control) without losing track of what was originally enabled.
Prerequisites
- Python 3.10+
- GitHub CLI authenticated (
gh auth login)
Workflow
The script operates in three distinct steps — always scan first, then disable, then re-enable when ready.
1. Scan
Writes state.json capturing current actions status for every repo:
python github-action-state.py scan
Output per repo:
has-workflows—.github/workflows/directory existsactions-ON/actions-OFF/actions-unknown— current enabled statearchived— repo is archived
2. Disable
Dry-run by default. Shows what would be disabled:
python github-action-state.py disable
Pass --confirm to execute:
python github-action-state.py disable --confirm
Only targets repos where actions_enabled=true at scan time.
3. Re-enable
Dry-run by default:
python github-action-state.py enable
Pass --confirm to execute:
python github-action-state.py enable --confirm
Only re-enables repos where actions_enabled=false and has_workflows=true — skips repos that had no workflows or were already disabled at scan time.
State file
state.json is written/updated in the current directory after each command. It records:
scanned_at— ISO 8601 timestamp of scandisabled_at/reenabled_at— timestamps added after those commands run- Per-repo:
name,full_name,archived,has_workflows,actions_enabled
Keep this file between disable and enable — it’s the source of truth for what to restore.
Script
#!/usr/bin/env python3
"""
GitHub Actions repo scanner and manager.
Ability to mass disable Github Actions for Personal account repos
And revert the changes based on a pre-generated state file
Commands:
python github-action-state.py scan -- scan all personal repos, write state.json
python github-action-state.py disable -- disable GHA in repos where actions_enabled=true
python github-action-state.py enable -- re-enable GHA in repos where previously_enabled=true
"""
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
STATE_FILE = Path("state.json")
def gh(endpoint: str, method: str = "GET", fields: dict | None = None) -> dict | list | None:
cmd = ["gh", "api", "--method", method, endpoint]
if fields:
for k, v in fields.items():
cmd += ["-f", f"{k}={str(v).lower()}"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return None
return json.loads(result.stdout) if result.stdout.strip() else None
def gh_paginated(endpoint: str) -> list:
cmd = ["gh", "api", "--paginate", endpoint]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERROR: {result.stderr.strip()}", file=sys.stderr)
return []
# gh --paginate may emit multiple JSON arrays — concatenate them
raw = result.stdout.strip()
items = []
decoder = json.JSONDecoder()
pos = 0
while pos < len(raw):
raw = raw[pos:].lstrip()
if not raw:
break
obj, idx = decoder.raw_decode(raw)
items.extend(obj if isinstance(obj, list) else [obj])
pos = idx
return items
def get_owner() -> str:
result = subprocess.run(["gh", "api", "user", "--jq", ".login"], capture_output=True, text=True)
if result.returncode != 0:
print("ERROR: gh auth failed. Run `gh auth login` first.", file=sys.stderr)
sys.exit(1)
return result.stdout.strip()
def has_workflows(owner: str, repo: str) -> bool:
data = gh(f"repos/{owner}/{repo}/contents/.github/workflows")
return isinstance(data, list) and len(data) > 0
def actions_status(owner: str, repo: str) -> bool | None:
data = gh(f"repos/{owner}/{repo}/actions/permissions")
if data is None:
return None
return data.get("enabled", False)
def cmd_scan():
owner = get_owner()
print(f"Owner: {owner}")
print("Fetching repos...")
repos = gh_paginated(f"users/{owner}/repos?type=owner&per_page=100")
print(f"Found {len(repos)} repos. Scanning...\n")
records = []
for r in repos:
name = r["name"]
full_name = r["full_name"]
archived = r.get("archived", False)
print(f" {full_name}", end="", flush=True)
wf = has_workflows(owner, name)
enabled = actions_status(owner, name)
record = {
"name": name,
"full_name": full_name,
"archived": archived,
"has_workflows": wf,
"actions_enabled": enabled,
}
records.append(record)
flags = []
if wf:
flags.append("has-workflows")
if enabled:
flags.append("actions-ON")
elif enabled is False:
flags.append("actions-OFF")
else:
flags.append("actions-unknown")
if archived:
flags.append("archived")
print(f" [{', '.join(flags)}]")
state = {
"scanned_at": datetime.now(timezone.utc).isoformat(),
"owner": owner,
"repos": records,
}
STATE_FILE.write_text(json.dumps(state, indent=2))
print(f"\nState written to {STATE_FILE}")
with_wf = [r for r in records if r["has_workflows"]]
enabled_count = sum(1 for r in records if r["actions_enabled"])
print(f"\nSummary:")
print(f" Total repos: {len(records)}")
print(f" Have workflows: {len(with_wf)}")
print(f" Actions enabled: {enabled_count}")
def load_state() -> dict:
if not STATE_FILE.exists():
print(f"ERROR: {STATE_FILE} not found. Run `scan` first.", file=sys.stderr)
sys.exit(1)
return json.loads(STATE_FILE.read_text())
def cmd_disable():
state = load_state()
owner = state["owner"]
targets = [r for r in state["repos"] if r["actions_enabled"] is True]
if not targets:
print("No repos with actions enabled. Nothing to do.")
return
print(f"Would disable GHA in {len(targets)} repos:")
for r in targets:
wf_note = " (has workflows)" if r["has_workflows"] else ""
print(f" {r['full_name']}{wf_note}")
print("\nDRY RUN — pass --confirm to actually disable.")
if "--confirm" not in sys.argv:
return
print("\nDisabling...")
for r in targets:
print(f" {r['full_name']}", end="", flush=True)
result = subprocess.run(
["gh", "api", "--method", "PUT",
f"repos/{owner}/{r['name']}/actions/permissions",
"-F", "enabled=false"],
capture_output=True, text=True
)
if result.returncode == 0:
r["actions_enabled"] = False
print(" OK")
else:
print(f" FAILED: {result.stderr.strip()}")
state["disabled_at"] = datetime.now(timezone.utc).isoformat()
STATE_FILE.write_text(json.dumps(state, indent=2))
print(f"\nState updated in {STATE_FILE}")
def cmd_enable():
state = load_state()
owner = state["owner"]
# Re-enable only repos that had actions enabled at scan time
targets = [r for r in state["repos"] if r.get("actions_enabled") is False and r.get("has_workflows")]
if not targets:
print("No repos to re-enable (looking for: actions_enabled=false AND has_workflows=true).")
return
print(f"Would re-enable GHA in {len(targets)} repos:")
for r in targets:
print(f" {r['full_name']}")
print("\nDRY RUN — pass --confirm to actually enable.")
if "--confirm" not in sys.argv:
return
print("\nEnabling...")
for r in targets:
print(f" {r['full_name']}", end="", flush=True)
result = subprocess.run(
["gh", "api", "--method", "PUT",
f"repos/{owner}/{r['name']}/actions/permissions",
"-F", "enabled=true"],
capture_output=True, text=True
)
if result.returncode == 0:
r["actions_enabled"] = True
print(" OK")
else:
print(f" FAILED: {result.stderr.strip()}")
state["reenabled_at"] = datetime.now(timezone.utc).isoformat()
STATE_FILE.write_text(json.dumps(state, indent=2))
print(f"\nState updated in {STATE_FILE}")
COMMANDS = {
"scan": cmd_scan,
"disable": cmd_disable,
"enable": cmd_enable,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print(f"Usage: python github-action-state.py [{' | '.join(COMMANDS)}]")
print(" Add --confirm to disable/enable commands to execute (default is dry-run).")
sys.exit(1)
COMMANDS[sys.argv[1]]()