No results found.

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 exists
  • actions-ON / actions-OFF / actions-unknown — current enabled state
  • archived — 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 scan
  • disabled_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]]()