Created
July 7, 2026 15:06
-
-
Save podhmo/bad6d9e5824755f1c49f4482706a6c0e to your computer and use it in GitHub Desktop.
Uploaded via Gist Uploader - 2026-07-07T15:06:24.945Z
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # /// 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