Skip to content

Instantly share code, notes, and snippets.

@podhmo
Created July 7, 2026 15:06
Show Gist options
  • Select an option

  • Save podhmo/bad6d9e5824755f1c49f4482706a6c0e to your computer and use it in GitHub Desktop.

Select an option

Save podhmo/bad6d9e5824755f1c49f4482706a6c0e to your computer and use it in GitHub Desktop.
Uploaded via Gist Uploader - 2026-07-07T15:06:24.945Z
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "ruamel.yaml>=0.18",
# ]
# ///
"""
GitHub Actions workflow (.github/workflows/*.yml) を走査し、
どの workflow / job / step が、どの `uses:` (action) を利用しているかを一覧化する。
さらに、設定ファイル (rules) を与えることで `uses:` のバージョン更新や
with: フィールドの追加/更新/削除を、YAMLの書式(コメント・クオート・キー順序)を
保持したまま書き込むことができる。
使い方:
# 一覧化 (読み取りのみ)
uv run gha_uses_manager.py [path ...] [--format table|json|csv]
# 設定ファイルのテンプレートを生成 (現状のusesから雛形を作る)
# デフォルトでGitHub Releases APIから最新版とmigration情報(deprecated/required/breaking)を取得しuse.toに反映する
# 認証は `gh` コマンドがあれば `gh api` を使う(gh auth login済みの認証、または gh 自体が見るGITHUB_TOKEN/GH_TOKENが優先される)。
# `gh` が無い環境では直接HTTPSでリクエストし、GITHUB_TOKEN/GH_TOKEN環境変数があれば認証に使う。
uv run gha_uses_manager.py [path ...] --init-config rules.yaml
# --bare を付けると通信せず from==to のまま出力する(TODOコメント付き)
uv run gha_uses_manager.py [path ...] --init-config rules.yaml --bare
# 設定ファイルに従って書き込み (-w/--write)
uv run gha_uses_manager.py [path ...] --config rules.yaml --write [--dry-run]
path にはyamlファイルとディレクトリを任意個数・混在で指定できる。
ディレクトリを指定した場合は配下の *.yml / *.yaml を再帰的にすべて対象にする。
省略した場合は ./.github/workflows を対象にする。
例:
uv run gha_uses_manager.py .github/workflows
uv run gha_uses_manager.py .github/workflows/ci.yml .github/workflows/release.yml
uv run gha_uses_manager.py .github/workflows some/other/dir extra.yml --format json > uses.json
uv run gha_uses_manager.py .github/workflows --init-config rules.yaml
uv run gha_uses_manager.py .github/workflows --config rules.yaml --dry-run
uv run gha_uses_manager.py .github/workflows --config rules.yaml --write
設定ファイル (rules) の形式 (YAML/JSON, トップレベルは `rules:` のリスト):
rules:
- file: ".github/workflows/ci.yml" # 省略可。glob(fnmatch)パターンでも良い。省略時は全ファイル対象
job: "build" # 省略可。job_id または job name にマッチ
step: "Checkout" # 省略可。step name / id / index(0始まり) にマッチ。省略時は該当usesを持つ全stepが対象
use:
from: "actions/checkout@v3" # 必須。現在のusesにマッチさせる値。"actions/checkout" のようにrefを省略すると
# ref違いも含めてrepo名だけでマッチする
to: "actions/checkout@v4" # 必須。書き込む新しいusesの値
remove_fields: ["with.token"] # 省略可。dot区切りのパスで指定したフィールドを削除
update_fields: # 省略可。既存フィールドの値を更新(無ければ新規作成しつつ警告)
with.fetch-depth: 0
add_fields: # 省略可。フィールドが無い場合のみ追加(既にあればスキップ)
with.persist-credentials: false
"""
from __future__ import annotations
import argparse
import csv
import fnmatch
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
@dataclass
class UsesEntry:
workflow_file: str
job_id: str
job_name: str | None
step_index: int
step_name: str | None
step_id: str | None
uses: str
action_repo: str
action_ref: str | None
line: int | None # action.yml 内での uses: の行番号 (1-indexed)
kind: str # "action" | "docker" | "local" | "reusable_workflow"
def classify_uses(uses: str) -> tuple[str, str, str | None]:
"""uses文字列を分類し、(kind, repo, ref) を返す。"""
if uses.startswith("./") or uses.startswith("../"):
return "local", uses, None
if uses.startswith("docker://"):
return "docker", uses, None
# reusable workflow: owner/repo/.github/workflows/xxx.yml@ref
if "/.github/workflows/" in uses:
repo, _, ref = uses.partition("@")
return "reusable_workflow", repo, (ref or None)
repo, sep, ref = uses.partition("@")
return "action", repo, (ref if sep else None)
def iter_steps(job_id: str, job_body) -> list[tuple[int, dict]]:
"""job body から steps のリストを (index, step_dict) で返す。
steps を持たない job (reusable workflow呼び出しの `uses:` を job直下に持つケース) にも対応。
"""
result: list[tuple[int, dict]] = []
# job 自体が reusable workflow 呼び出しの場合 (steps を持たない)
if isinstance(job_body, dict) and "uses" in job_body and "steps" not in job_body:
result.append((-1, job_body)) # step_index=-1 は「job直下のuses」を示す
return result
steps = (job_body or {}).get("steps") or []
for idx, step in enumerate(steps):
if isinstance(step, dict):
result.append((idx, step))
return result
def find_uses_line(step_or_job: dict) -> int | None:
"""ruamel.yamlのCommentedMapから `uses` キーの行番号を取り出す。"""
try:
lc = step_or_job.lc # LineCol情報 (ruamel.yaml拡張)
if "uses" in getattr(lc, "data", {}):
return lc.data["uses"][0] + 1 # 0-indexed -> 1-indexed
except Exception:
pass
return None
def scan_workflow(path: Path) -> list[UsesEntry]:
yaml = YAML()
yaml.preserve_quotes = True
with path.open("r", encoding="utf-8") as f:
try:
data = yaml.load(f)
except Exception as e:
print(f"[WARN] YAML parse失敗: {path} ({e})", file=sys.stderr)
return []
if not isinstance(data, dict):
return []
entries: list[UsesEntry] = []
jobs = data.get("jobs") or {}
if not isinstance(jobs, dict):
return entries
for job_id, job_body in jobs.items():
job_name = None
if isinstance(job_body, dict):
job_name = job_body.get("name")
for step_index, step in iter_steps(job_id, job_body):
uses = step.get("uses") if isinstance(step, dict) else None
if not uses:
continue
kind, repo, ref = classify_uses(str(uses))
entries.append(
UsesEntry(
workflow_file=str(path),
job_id=job_id,
job_name=job_name,
step_index=step_index,
step_name=step.get("name") if step_index != -1 else None,
step_id=step.get("id") if step_index != -1 else None,
uses=str(uses),
action_repo=repo,
action_ref=ref,
line=find_uses_line(step),
kind=kind,
)
)
return entries
# ---------------------------------------------------------------------------
# 設定ファイル (rules) を使った --write 機能
# ---------------------------------------------------------------------------
def detect_indent(text: str) -> tuple[int, int, int]:
"""ファイル本文からブロックシーケンスのインデント幅(mapping, sequence, offset)を推測する。
ruamel.yaml は元ファイルのインデント幅を自動で維持しないため、
書き込み前にファイルごとの実際のスタイルを検出して yaml.indent() に反映する。
検出できない場合はGitHub Actionsで最も一般的な (mapping=2, sequence=4, offset=2) を使う。
"""
mapping, sequence, offset = 2, 4, 2
lines = text.splitlines()
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith("#") or not stripped.endswith(":"):
continue
indent_a = len(line) - len(line.lstrip(" "))
for j in range(i + 1, len(lines)):
nxt = lines[j]
if not nxt.strip():
continue
indent_b = len(nxt) - len(nxt.lstrip(" "))
if indent_b <= indent_a:
break
nxt_stripped = nxt.strip()
if nxt_stripped.startswith("- "):
offset = indent_b - indent_a
sequence = offset + 2
else:
m = indent_b - indent_a
if m > 0:
mapping = m
break
return mapping, sequence, offset
def load_config(path: Path) -> list[dict]:
"""設定ファイル(YAML or JSON)を読み込み、rulesのリストを返す。"""
text = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".json":
data = json.loads(text)
else:
y = YAML(typ="safe")
data = y.load(text)
if data is None:
return []
if isinstance(data, list):
rules = data
elif isinstance(data, dict):
rules = data.get("rules") or []
else:
raise ValueError(f"設定ファイルの形式が不正です(リストまたはrules:を持つdictが必要): {path}")
for i, r in enumerate(rules):
if not isinstance(r, dict):
raise ValueError(f"rules[{i}] がdictではありません: {r!r}")
use = r.get("use")
if not isinstance(use, dict) or not use.get("from") or not use.get("to"):
raise ValueError(f"rules[{i}] には use.from と use.to が必須です: {r!r}")
return rules
def _split_dotted(path_str: str) -> list[str]:
return [p for p in path_str.split(".") if p]
def _get_in(mapping: dict, keys: list[str]) -> tuple[bool, Any]:
cur = mapping
for k in keys:
if not isinstance(cur, dict) or k not in cur:
return False, None
cur = cur[k]
return True, cur
def _set_in(mapping: dict, keys: list[str], value: Any) -> bool:
"""ネストした辞書に値をセットする。中間キーが無ければCommentedMapを作って掘る。
戻り値: セット前にそのキーが既に存在していたか。
"""
cur = mapping
for k in keys[:-1]:
nxt = cur.get(k) if isinstance(cur, dict) else None
if not isinstance(nxt, dict):
nxt = CommentedMap()
cur[k] = nxt
cur = nxt
existed = isinstance(cur, dict) and keys[-1] in cur
cur[keys[-1]] = value
return existed
def _del_in(mapping: dict, keys: list[str]) -> bool:
cur = mapping
for k in keys[:-1]:
if not isinstance(cur, dict) or k not in cur:
return False
cur = cur[k]
if isinstance(cur, dict) and keys[-1] in cur:
del cur[keys[-1]]
return True
return False
def _use_matches(rule_from: str, uses: str) -> bool:
"""rule.use.from が現在のusesにマッチするか判定する。
from に @ref が無い場合はrepo名だけで(refを問わず)マッチする。
"""
if "@" in rule_from:
return rule_from == uses
repo, _, _ref = uses.partition("@")
return repo == rule_from
def _step_matches(rule_step: Any, step_index: int, step_name: str | None, step_id: str | None) -> bool:
if rule_step is None:
return True
if step_index == -1:
# job直下のuses(reusable workflow呼び出し)は名前を持たないので、
# rule側にstep指定があれば "job" というキーワードのみ許容する
return str(rule_step).lower() == "job"
rs = str(rule_step)
if rs.startswith("#"):
try:
return int(rs[1:]) == step_index
except ValueError:
return False
if rs == str(step_index):
return True
if step_name is not None and rs == step_name:
return True
if step_id is not None and rs == step_id:
return True
return False
def rule_matches(
rule: dict,
file_path: Path,
job_id: str,
job_name: str | None,
step_index: int,
step_name: str | None,
step_id: str | None,
uses: str,
) -> bool:
rf = rule.get("file")
if rf:
candidates = {str(file_path), file_path.name, str(file_path.as_posix())}
if not any(fnmatch.fnmatch(c, rf) or c == rf for c in candidates):
return False
rj = rule.get("job")
if rj and rj not in (job_id, job_name):
return False
if not _step_matches(rule.get("step"), step_index, step_name, step_id):
return False
return _use_matches(rule["use"]["from"], uses)
@dataclass
class ChangeLog:
workflow_file: str
job_id: str
step_label: str
kind: str # "uses" | "remove_field" | "update_field" | "add_field" | "skip_field"
detail: str
def apply_rule_to_step(step: dict, rule: dict, ctx_label: str) -> list[ChangeLog]:
"""1つのstep(またはjob直下のuses)にruleを適用し、変更ログを返す。"""
changes: list[ChangeLog] = []
old_uses = step.get("uses")
new_uses = rule["use"]["to"]
if old_uses != new_uses:
col = 0
try:
col = step.lc.data["uses"][1]
except Exception:
col = 0
step["uses"] = new_uses
try:
step.yaml_set_comment_before_after_key(
"uses", before=f"updated by rule: {old_uses} -> {new_uses}", indent=col
)
except Exception:
pass
changes.append(ChangeLog(ctx_label, "", "", "uses", f"{old_uses} -> {new_uses}"))
for path_str in rule.get("remove_fields") or []:
keys = _split_dotted(path_str)
if _del_in(step, keys):
changes.append(ChangeLog(ctx_label, "", "", "remove_field", path_str))
else:
changes.append(ChangeLog(ctx_label, "", "", "skip_field", f"remove対象なし: {path_str}"))
for path_str, value in (rule.get("update_fields") or {}).items():
keys = _split_dotted(path_str)
existed, _ = _get_in(step, keys)
_set_in(step, keys, value)
note = "" if existed else " (既存キーが無かったため新規作成)"
changes.append(ChangeLog(ctx_label, "", "", "update_field", f"{path_str} = {value!r}{note}"))
for path_str, value in (rule.get("add_fields") or {}).items():
keys = _split_dotted(path_str)
existed, _ = _get_in(step, keys)
if existed:
changes.append(ChangeLog(ctx_label, "", "", "skip_field", f"add対象は既に存在: {path_str}"))
else:
_set_in(step, keys, value)
changes.append(ChangeLog(ctx_label, "", "", "add_field", f"{path_str} = {value!r}"))
return changes
def write_workflows(files: list[Path], rules: list[dict], dry_run: bool) -> list[ChangeLog]:
"""rulesに従って各workflowファイルを書き換える(dry_runならメモリ上のみ)。
ファイルごとに元のインデント幅を検出してから読み込み/書き出しすることで、
ruamel.yamlのデフォルトインデントによる意図しない差分を防ぐ。
"""
all_changes: list[ChangeLog] = []
for f in files:
text = f.read_text(encoding="utf-8")
mapping, sequence, offset = detect_indent(text)
yaml = YAML()
yaml.preserve_quotes = True
yaml.width = 4096 # 長い行の折り返しによる意図しない差分を防ぐ
yaml.indent(mapping=mapping, sequence=sequence, offset=offset)
try:
data = yaml.load(text)
except Exception as e:
print(f"[WARN] YAML parse失敗: {f} ({e})", file=sys.stderr)
continue
if not isinstance(data, dict):
continue
jobs = data.get("jobs") or {}
if not isinstance(jobs, dict):
continue
file_changed = False
for job_id, job_body in jobs.items():
job_name = job_body.get("name") if isinstance(job_body, dict) else None
for step_index, step in iter_steps(job_id, job_body):
uses = step.get("uses") if isinstance(step, dict) else None
if not uses:
continue
step_name = step.get("name") if step_index != -1 else None
step_id = step.get("id") if step_index != -1 else None
step_label = "(job-level)" if step_index == -1 else (step_name or step_id or f"#{step_index}")
ctx_label = f"{f}::{job_name or job_id}::{step_label}"
for rule in rules:
if rule_matches(rule, f, job_id, job_name, step_index, step_name, step_id, str(uses)):
changes = apply_rule_to_step(step, rule, ctx_label)
if changes:
file_changed = True
all_changes.extend(changes)
if file_changed and not dry_run:
with f.open("w", encoding="utf-8") as fh:
yaml.dump(data, fh)
return all_changes
GITHUB_API_BASE = "https://api.github.com"
_VER_RE = re.compile(r"^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$")
_MIGRATION_KEYWORDS = re.compile(r"deprecat|required|breaking", re.IGNORECASE)
_HAS_GH = shutil.which("gh") is not None
def _github_api_get(path: str) -> Any:
"""GitHub APIを叩く。`gh`コマンドがあれば `gh api` を使う
(GITHUB_TOKEN/GH_TOKEN環境変数 > `gh auth login` の認証、の優先順位はghが内部で処理してくれる)。
`gh`が無い場合は直接HTTPSでリクエストし、GITHUB_TOKEN/GH_TOKEN環境変数があれば認証に使う。
"""
if _HAS_GH:
try:
proc = subprocess.run(
["gh", "api", path],
capture_output=True, text=True, timeout=20,
)
if proc.returncode == 0:
return json.loads(proc.stdout)
# gh未認証やAPIエラーの場合は直接HTTPSにフォールバック
except Exception:
pass
url = f"{GITHUB_API_BASE}/{path}"
headers = {"User-Agent": "gha_uses_manager.py", "Accept": "application/vnd.github+json"}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
def fetch_releases(repo: str) -> list[dict] | None:
"""repos/{repo}/releases を取得する。取得できなければNone(通信/404/レート制限等)。"""
try:
data = _github_api_get(f"repos/{repo}/releases?per_page=100")
except Exception as e:
print(f"[WARN] releases取得失敗: {repo} ({e})", file=sys.stderr)
return None
return data if isinstance(data, list) else None
def _ref_style(ref: str) -> str:
"""'major' (v4) | 'full' (v4.2.1) | 'other' (SHA/ブランチ名など、自動更新不可)"""
if not ref:
return "other"
m = _VER_RE.match(ref)
if not m:
return "other"
return "major" if m.group(2) is None else "full"
def _tag_sort_key(tag: str | None) -> tuple[int, int, int]:
m = _VER_RE.match(tag or "")
if not m:
return (-1, -1, -1)
return tuple(int(g) if g else 0 for g in m.groups())
def pick_update_target(releases: list[dict], current_ref: str) -> tuple[str | None, list[dict]]:
"""現在のref(major固定/完全pin)のスタイルを維持したまま更新先タグを決める。
戻り値: (更新先タグ or None(自動更新不可/すでに最新), currentより新しいreleaseのリスト)
"""
style = _ref_style(current_ref)
if style == "other":
return None, []
valid = [(r, m) for r in releases if (m := _VER_RE.match(r.get("tag_name") or ""))]
if not valid:
return None, []
if style == "major":
cur_major = int(_VER_RE.match(current_ref).group(1))
has_v = current_ref.lower().startswith("v")
best_major = max(int(m.group(1)) for _, m in valid)
newer = [r for r, m in valid if int(m.group(1)) > cur_major]
if best_major <= cur_major:
return None, []
return (f"v{best_major}" if has_v else str(best_major)), newer
else:
cur_key = _tag_sort_key(current_ref)
newer = sorted(
(r for r, m in valid if _tag_sort_key(r.get("tag_name")) > cur_key),
key=lambda r: _tag_sort_key(r.get("tag_name")),
reverse=True,
)
if not newer:
return None, []
return newer[0].get("tag_name"), newer
def extract_migration_notes(newer_releases: list[dict], max_lines: int = 5) -> list[str]:
"""current以降のreleaseノートから deprecated/required/breaking を含む行を抜き出す。
書式はリポジトリごとにバラバラなので、完全な構造化は期待せずヒント程度に留める。
"""
notes: list[str] = []
for r in newer_releases:
body = r.get("body") or ""
for line in body.splitlines():
line = line.strip().lstrip("-*# ").strip()
if line and _MIGRATION_KEYWORDS.search(line):
notes.append(f"[{r.get('tag_name')}] {line}"[:200])
if len(notes) >= max_lines:
return notes
return notes
def generate_config_template(entries: list[UsesEntry], out_path: Path, bare: bool) -> None:
"""現状の uses: 一覧から、rules設定ファイルの雛形を生成する。
action種別(kind="action")の一意な uses値ごとに1ルールを作成する。
file/job/step は省略した状態(=全ファイル・全箇所に適用)で出力するので、
範囲を絞りたい場合は手動で追記すること。
bare=False (デフォルト) の場合、各actionについてGitHub Releases APIを問い合わせ、
現在のref(major固定 or 完全pin)のスタイルを維持したまま最新版を use.to に設定し、
current〜最新の間のreleaseノートから deprecated/required/breaking を含む行を
use.to の直前にコメントとして挿入する。bare=True の場合は通信を行わず from==to のまま出力する。
"""
seen: dict[str, None] = {}
for e in entries:
if e.kind != "action":
continue
if e.uses not in seen:
seen[e.uses] = None
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
rules = []
for uses in seen:
repo, sep, ref = uses.partition("@")
to_value = uses
notes: list[str] = []
status_note: str | None = None
if bare:
status_note = "TODO: 更新先バージョンを指定してください(--bareのため自動取得はスキップ)"
elif not sep:
status_note = "refが指定されていないため自動更新の対象外です"
else:
releases = fetch_releases(repo)
if releases is None:
status_note = (
"releases取得失敗(レート制限 or releases未使用の可能性)。"
"`gh auth login`済みならそれが使われます。"
"未認証の場合は GITHUB_TOKEN/GH_TOKEN 環境変数を設定してください。手動で確認してください"
)
else:
target, newer = pick_update_target(releases, ref)
if target is None:
status_note = "既に最新、または自動更新できない形式のrefです"
else:
to_value = f"{repo}@{target}"
notes = extract_migration_notes(newer)
if not notes and newer:
status_note = f"{len(newer)}件の新しいリリースがありますが、deprecated/required/breaking記載は見つかりませんでした"
use_map = CommentedMap({"from": uses, "to": to_value})
comment_lines = notes + ([status_note] if status_note else [])
if comment_lines:
use_map.yaml_set_comment_before_after_key("to", before="\n".join(comment_lines))
rules.append(
CommentedMap(
{
"file": "*", # 対象を絞る場合は例: ".github/workflows/ci.yml" やglobパターン
"job": None, # 対象を絞る場合は job_id または job name
"step": None, # 対象を絞る場合は step name / id / "#index" / "job"(job直下のuses)
"use": use_map,
"remove_fields": [],
"update_fields": CommentedMap(),
"add_fields": CommentedMap(),
}
)
)
doc = CommentedMap({"rules": rules})
with out_path.open("w", encoding="utf-8") as fh:
fh.write(
"# gha_uses_manager.py --init-config で生成した雛形。\n"
+ ("# --bare指定のため、GitHub Releases APIへの問い合わせは行っていません。\n" if bare else
"# GitHub Releases APIから取得した最新版を use.to に設定済みです。内容を確認してください。\n")
+ "# use.to を確認・必要なら書き換えてから --write を実行してください。\n"
"# file / job / step を null のままにすると、その条件は無視され対象が広がります。\n"
"# remove_fields / update_fields / add_fields は不要なら空のままで構いません。\n"
)
yaml.dump(doc, fh)
YAML_SUFFIXES = (".yml", ".yaml")
def resolve_yaml_paths(inputs: list[str]) -> list[Path]:
"""コマンドライン引数(ファイル or ディレクトリの混在)を、
実際に読み込むyamlファイルのリストに展開する。
ディレクトリが渡された場合は配下の *.yml / *.yaml を再帰的にすべて対象にする。
"""
resolved: list[Path] = []
seen: set[Path] = set()
for raw in inputs:
p = Path(raw)
if not p.exists():
print(f"[WARN] 存在しないパスをスキップ: {raw}", file=sys.stderr)
continue
if p.is_dir():
found = sorted(
f for f in p.rglob("*")
if f.is_file() and f.suffix.lower() in YAML_SUFFIXES
)
if not found:
print(f"[WARN] {p} 配下に .yml/.yaml が見つかりません", file=sys.stderr)
found_list = found
elif p.is_file():
if p.suffix.lower() not in YAML_SUFFIXES:
print(f"[WARN] yaml以外のファイルをスキップ: {p}", file=sys.stderr)
continue
found_list = [p]
else:
continue
for f in found_list:
rf = f.resolve()
if rf not in seen:
seen.add(rf)
resolved.append(f)
return resolved
def collect(inputs: list[str]) -> list[UsesEntry]:
all_entries: list[UsesEntry] = []
files = resolve_yaml_paths(inputs)
if not files:
print("[WARN] 対象となるyamlファイルが見つかりませんでした", file=sys.stderr)
for f in files:
all_entries.extend(scan_workflow(f))
return all_entries
def print_table(entries: list[UsesEntry]) -> None:
if not entries:
print("該当するusesが見つかりませんでした。")
return
headers = ["workflow", "job", "step", "uses", "kind"]
rows = []
for e in entries:
step_label = "(job-level)" if e.step_index == -1 else (e.step_name or e.step_id or f"#{e.step_index}")
rows.append(
[
Path(e.workflow_file).name,
e.job_name or e.job_id,
step_label,
e.uses,
e.kind,
]
)
widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def fmt_row(r):
return " ".join(str(v).ljust(w) for v, w in zip(r, widths))
print(fmt_row(headers))
print(" ".join("-" * w for w in widths))
for r in rows:
print(fmt_row(r))
def print_csv(entries: list[UsesEntry]) -> None:
writer = csv.writer(sys.stdout)
writer.writerow(
["workflow_file", "job_id", "job_name", "step_index", "step_name", "step_id",
"uses", "action_repo", "action_ref", "line", "kind"]
)
for e in entries:
writer.writerow(
[e.workflow_file, e.job_id, e.job_name or "", e.step_index,
e.step_name or "", e.step_id or "", e.uses, e.action_repo,
e.action_ref or "", e.line or "", e.kind]
)
def print_changes(changes: list[ChangeLog], dry_run: bool) -> None:
if not changes:
print("変更対象はありませんでした。")
return
mode = "[DRY-RUN] " if dry_run else ""
for c in changes:
print(f"{mode}{c.workflow_file}: [{c.kind}] {c.detail}")
print(f"\n{mode}合計 {len(changes)} 件の変更{'(適用予定)' if dry_run else 'を適用しました'}。")
def main() -> None:
parser = argparse.ArgumentParser(description="GitHub Actions workflowのusesをjob/step単位で一覧化/更新する")
parser.add_argument(
"paths",
nargs="*",
default=[".github/workflows"],
help=(
"対象とするyamlファイルおよび/またはディレクトリのパス(複数指定可)。"
"ディレクトリを指定した場合は配下の *.yml/*.yaml を再帰的にすべて対象にする。"
"省略時は ./.github/workflows を対象にする。"
),
)
parser.add_argument("--format", choices=["table", "json", "csv"], default="table",
help="一覧表示のフォーマット(--writeや--init-config指定時は無視される)")
parser.add_argument("--config", "-c", metavar="PATH",
help="rules設定ファイル(YAML/JSON)のパス。--writeと併用する")
parser.add_argument("--write", "-w", action="store_true",
help="--configで指定したrulesに従って対象yamlファイルを書き換える(書式は保持)")
parser.add_argument("--dry-run", action="store_true",
help="--write指定時、実際には書き込まず変更内容だけ表示する")
parser.add_argument("--init-config", metavar="PATH",
help="現状のuses一覧からrules設定ファイルの雛形を生成して終了する"
"(GitHub Releases APIから最新版とmigration情報を自動取得してuse.toに反映する)")
parser.add_argument("--bare", action="store_true",
help="--init-config時、GitHub Releases APIへの問い合わせを行わずuse.from==toのまま出力する")
args = parser.parse_args()
if args.init_config:
entries = collect(args.paths)
generate_config_template(entries, Path(args.init_config), bare=args.bare)
print(f"設定ファイルの雛形を書き出しました: {args.init_config}")
print("use.to を更新先のバージョンに書き換えてから --config <path> --write を実行してください。")
return
if args.write:
if not args.config:
parser.error("--write を使うには --config <path> が必要です")
rules = load_config(Path(args.config))
files = resolve_yaml_paths(args.paths)
if not files:
print("[WARN] 対象となるyamlファイルが見つかりませんでした", file=sys.stderr)
return
changes = write_workflows(files, rules, dry_run=args.dry_run)
print_changes(changes, dry_run=args.dry_run)
return
entries = collect(args.paths)
if args.format == "json":
print(json.dumps([asdict(e) for e in entries], ensure_ascii=False, indent=2))
elif args.format == "csv":
print_csv(entries)
else:
print_table(entries)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment