みなさんは自分が管理している GitHub リポジトリ直下の README.md には何を書かれていますか?
プログラムの概要や、使い方、ディレクトリ構成などを記載する人がほとんどだと思います。
一方で、私はリポジトリの一番上にヒーロー画像を置くことが多いです。このヒーロー画像はリポジトリ名のロゴだったり、ロゴに何かしらの意匠を加えたものを置くことが多いです。
作業をする場合に、リポジトリは 1 日に何度も見る画面です。そのため自分にとってヒーロー画像は自分の PC の壁紙にも似た感覚です。
しかしこのヒーロー画像がいつも同じだとなんか飽きるなと思いませんか。そこで自分はこの記事で GitHub Actions を使って、このヒーロー画像を自動で切り替える方法を記述します。
サンプル github リポジトリ
なぜ GitHub Actions なのか?
GitHub リポジトリのページは JS が動かないため、動的に画像を取り替えることが不可能です。他の方法として README.md から画像を取り出す API を外に設けてヒーロー画像のローテーションを実装することも可能ですが、あまりにもオーバースペックです。
そこで GitHub Actions を利用することで、比較的簡単に、ヒーロー画像を用意する以外は他に何も用意せず実装できてしまいます。
完成イメージ
サンプルリポジトリ hisashi-ito/readme-hero-rota を fork して assets/heroes/ に好きな画像を投入すれば、毎朝 README のトップが切り替わることを実感できます。
すぐ動かしたい
説明より先に動かしたい方向け、5 ステップ。基本はブラウザの GitHub Web UI だけで完結します(画像投入だけローカル CLI と選択可)。
1. fork する [Web UI]
サンプルリポジトリ を開き、ページ右上の Fork ボタン → 自分のアカウントにコピー。
2. ヒーロー画像を投入する [Web UI または ローカル]
fork した自分のリポジトリで assets/heroes/ ディレクトリを開いて、サンプル画像(hero_1.png 等)を削除し、自分の画像をアップロード。
- Web UI でやる場合:
assets/heroes/内で Add file → Upload files にドラッグ&ドロップ → Commit changes - ローカルでやる場合:
git clone https://github.com/<自分>/readme-hero-rota.git→ ファイル差し替え →git push
対応拡張子: .png / .jpg / .jpeg / .webp / .gif。ファイル名は自由。
3. workflow に書き込み権限を与える [Web UI]
自分の fork で Settings タブ → 左サイドバー Actions → General → ページ下部の Workflow permissions まで降りる → Read and write permissions を選択 → Save。
これを忘れると workflow は動くが最後の git push で 403 になります。
4. 手動で 1 回キックして動作確認 [Web UI]
Actions タブ → 左サイドバー Rotate README hero → 画面右の Run workflow → Branch: main のまま緑の Run workflow ボタンで実行。
数秒〜十数秒で runs リストに新ジョブが追加され、緑のチェックが付けば成功。README を再読込するとヒーローが切り替わっているはずです。
5. 翌朝、自動切替を確認 [自動 / 操作なし]
JST 07:00 (UTC 22:00) ごろに cron が発火し、自動で新ヒーローに切り替わります。GHA cron の遅延で実際の切替は JST 07:00〜08:00 程度の幅で起きます。
仕組み・設計・cron スロット選定・注意点は以降のセクションで解説します。
さらに踏み込んだ手順は docs/setup.md と docs/troubleshooting.md を参照。
副作用
GitHub Actions が特定の時間に commit / push するため、commit log の中に本処理のログが入り、履歴が見にくくなるという欠点はありますので、ご留意の上ご利用ください。
アーキテクチャ
全体像(ディレクトリ構成と動作フロー)
your-repo/
├── README.md ← <!-- HERO_START --> マーカーを置く
├── .github/workflows/
│ └── rotate-hero.yml ← cron で動く workflow(下に全文)
└── assets/heroes/ ← ヒーロー画像プール
├── hero_1.png
├── hero_2.png
└── hero_3.png ...
cron 発火時に workflow がやること:
- リポジトリを checkout
-
assets/heroes/から「現在の画像以外」をランダム選択 - README.md の HERO ブロック内の
<img src>だけを差し替え -
github-actions[bot]で commit + push
重要な設計上のポイント
| 観点 | 採用 | 理由 |
|---|---|---|
| マーカー方式 | <!-- HERO_START --> ... <!-- HERO_END --> |
HTML/Markdown 両対応、<img> 以外の要素(<p> 等)も挟める |
src だけ swap |
width、alt 等の他属性は保持 |
デザインを workflow に任せず手動で制御できる |
| 現在の画像を除外して random | 同じ画像が連日来ない | UX 改善、地味に効く |
| stdlib only | 追加 install なし | 起動が速い、依存リスクなし |
README 側の HERO ブロック
<!-- HERO_START -->
<p align="center">
<img src="./assets/heroes/hero_1.png" width="80%">
</p>
<!-- HERO_END -->
<p align="center"> ラッパや width="80%" は workflow が触らないので、デザインは思いのまま。
workflow 全文(コピペ可)
.github/workflows/rotate-hero.yml:
name: Rotate README hero
on:
schedule:
# GitHub Actions cron は UTC。UTC 22:00 = JST 07:00。
- cron: "0 22 * * *"
workflow_dispatch:
permissions:
contents: write
env:
# 2026-06-16 以降 Node.js 24 がデフォルトになるので明示 opt-in。
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Rotate hero image
run: |
python3 <<'PY'
from pathlib import Path
import random
import re
readme = Path("README.md")
hero_dir = Path("assets/heroes")
if not readme.exists():
raise SystemExit("README.md not found")
if not hero_dir.exists():
raise SystemExit(f"Hero directory not found: {hero_dir}")
images = sorted([
p for p in hero_dir.iterdir()
if p.is_file() and p.suffix.lower() in [".png", ".jpg", ".jpeg", ".webp", ".gif"]
])
if not images:
raise SystemExit("No hero images found in assets/heroes")
text = readme.read_text(encoding="utf-8")
block_re = re.compile(r"<!-- HERO_START -->.*?<!-- HERO_END -->", re.S)
m = block_re.search(text)
if not m:
raise SystemExit("HERO_START / HERO_END block not found")
sm = re.search(r'<img\s+[^>]*src="([^"]+)"', m.group(0))
current_src = sm.group(1) if sm else None
candidates = images
if current_src:
without_current = [
p for p in images
if f"./{p.as_posix()}" != current_src and p.as_posix() != current_src
]
if without_current:
candidates = without_current
selected = random.choice(candidates)
image_path = f"./{selected.as_posix()}"
new_block = re.sub(
r'(<img\s+[^>]*src=")([^"]+)(")',
lambda mm: mm.group(1) + image_path + mm.group(3),
m.group(0), count=1,
)
text = block_re.sub(lambda _: new_block, text, count=1)
readme.write_text(text, encoding="utf-8")
print(f"Selected: {image_path}")
PY
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git diff --quiet; then
echo "No changes"
exit 0
fi
git add README.md
git commit -m "Rotate README hero image [skip ci]"
git push
ロジックは Python で 40 行、workflow yaml と合わせても 90 行未満。
cron 発火時刻の選び方
GitHub Actions の cron は公式に "best-effort" とされています。docs にも「混雑時間帯で遅延しうる」と書かれていますが、具体的にどれくらい遅延するかは書かれていません。
コミュニティで広く言われている目安は、
- 通常(off-peak): 数分〜十数分
- 混雑時間帯(米国・欧州が両方アクティブな UTC 14:00–19:00 帯): 30 分〜1 時間超
- 最悪ケース: 数時間 / その回はスキップ
くらい。UTC slot 選定で体感が大きく変わります。混雑しにくいスロットを選ぶための考え方を整理します。
スロット選定の考え方
| UTC slot | 現地時間(対応) | グローバル状況 |
|---|---|---|
| 14:00–19:00 | EDT 10:00–15:00 / PDT 07:00–12:00 / CET 16:00–21:00 | 米国 + 欧州が両方アクティブ(混雑ゾーン) |
| 22:00–08:00 | EDT 18:00–04:00 / PDT 15:00–01:00 / CET 00:00–10:00 | 米国 EOD → 欧州 起床までの隙間(穏やか) |
なぜ peak time の UTC が悪いのか
GHA の scheduled workflow 実行キューは全 GitHub で共有されているので、米国・欧州の業務時間中はキューが詰まります。
- 下軸 UTC: cron に書く値そのもの(
cron: "0 22 * * *"の22がここ) - 上軸 JST: 日本時間で読み解く用(深夜帯に走る cron を一目で判断できる)
- 青バー: 各地域の業務時間(09:00–17:00 ローカル)を UTC に展開
- 赤帯: UTC 14:00–19:00(JST 23:00–04:00)— 米国朝〜昼 + 欧州夕方が重なる混雑ゾーン
- 緑帯: UTC 22:00–08:00(JST 07:00–17:00)— 米国 EOD 後 + 欧州 就寝の穏やかなウィンドウ
:00 より :30 を選ぶ
「毎時0分」に設定されている cron が世界中で大量にあるため、:00 マークはさらに混雑します。:30 の方が空いている傾向。
スケジュール変更直後の罠
workflow を変更した直後の最初の scheduled fire はスキップされる確率が高い
GitHub のスケジューラが新しい cron 設定を認識するのに 10〜15 分のラグがある模様。push 後すぐの slot は信用せず、手動 dispatch で先に動作確認を。
推奨スロット
| あなたの目的 | 推奨 UTC slot | 日本時間 |
|---|---|---|
| 「朝起きたら新ヒーローに変わってる」体験 | 0 22 * * * |
JST 07:00 |
| 「とにかく遅延少なく」 | 30 6 * * * |
JST 15:30(欧米共に静か) |
| 深夜帯ロマン重視 | 30 17 * * * |
JST 02:30(混雑時間帯なので遅延しがち) |
本サンプルリポジトリはデフォルト UTC 22:00 を採用しています。
Node.js 24 移行への対応(2026-06-16)
GitHub の runner は 2026-06-16 から Node.js 24 がデフォルトになります。actions/checkout@v4 等の Node.js 20 ベースの action を使い続けると将来壊れます。
二段の対策:
-
actions/checkout@v5を使う(Node.js 24 native) -
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"を workflow env に追加(他の Node.js 20 action にも opt-in 効果)
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
これでデフォルト切替の前に "もう Node.js 24 で動いてる" 状態にできます。
必須セットアップ: workflow permissions を切り替える
GitHub Actions のデフォルト権限は read-only です。本テンプレのように workflow が git push する場合、リポジトリの Actions 設定で書き込み権限を一度有効化する必要があります(これを忘れると workflow は走るが最後の push で 403 になります)。
手順
- リポジトリの Settings タブを開く
- 左サイドバーで Actions → General をクリック
- ページ下部 Workflow permissions セクションまでスクロール
- Read and write permissions のラジオボタンに切替
- Save ボタンで保存
これで permissions: contents: write を要求する workflow が git push できるようになります。
補足
- 「Allow GitHub Actions to create and approve pull requests」は OFF のままで問題ありません(本テンプレは PR を作らず直接 commit します)
- yaml の
permissions: contents: write指定は「リポジトリ側の上限の範囲内で要求できる」ものです。リポジトリ設定が read-only のままだと、yaml に何を書いても push は通りません(yaml は下限のみ調整可) - この設定は fork 元 / fork 先で独立。fork した側でも同じ操作が必要です
設定し忘れた時の症状
workflow log の最後にこれが出ます:
remote: Permission to <owner>/<repo>.git denied to github-actions[bot]
fatal: unable to access 'https://github.com/<owner>/<repo>.git/': The requested URL returned error: 403
→ 上の手順で write 権限を ON にして、Actions タブから手動 re-run。
画面から手動でトリガーする
workflow に workflow_dispatch: を入れているおかげで、cron 発火を待たず GitHub のブラウザ画面から即時実行できます。動作確認、cron がスキップされた朝のリカバリ、「今すぐ違うヒーローを見たい」気分の時、いずれも 1 クリックで OK。
手順
- リポジトリの上部ナビから Actions タブを開く
- 左サイドバーのワークフロー一覧から Rotate README hero を選択
- ワークフロー画面右側に "This workflow has a
workflow_dispatchevent trigger" のバナーが出ているはずなので、その横の Run workflow ボタンを押す - ドロップダウンで Branch: main のまま、緑色の Run workflow ボタンで実行
- 数秒〜十数秒で runs リストに新しいジョブが追加され、緑のチェックが付けば完了
- README を再読込すると新しいヒーローに切り替わっている
こんな時に使う
| シーン | 使い方 |
|---|---|
| fork 直後の動作確認 | cron 待ちせず即実行して、commit & push が通るか確認 |
| 画像プール入れ替え後 | プールを新調したら手動で 1 回回して切替の見え方を確認 |
| cron がスキップされた朝 | UTC 22:00 が混んでスキップされたら、起きてから手動で回す |
| 別ブランチでテスト | Run workflow ダイアログで Branch を選べる(workflow_dispatch の標準機能) |
Run workflow ボタンが出ない場合
workflow_dispatch: が yaml に書かれていない、あるいは workflow ファイルがまだ default branch に push されていないと、ボタンは表示されません。
- workflow yaml に
workflow_dispatch:が入っているか確認 - そのファイルが main(default branch)に push されているか確認(他ブランチに居るだけだと UI に出ない)
anti-test-loop ガード — 同じリポジトリの test job をムダに回さない
bot commit が走るたびにテストが回ると CI 時間がもったいない。二段防御で:
1. commit message に [skip ci]
ほとんどの CI サービス(GHA 自身も)は [skip ci] を含む commit を見ると自動的に build をスキップします。本 workflow の commit message は:
Rotate README hero image [skip ci]
2. 他 workflow に paths-ignore
[skip ci] を尊重しないテストランナーや、念のための保険として、test workflow 側で paths-ignore:
on:
push:
paths-ignore:
- "README.md"
- "assets/heroes/**"
- ".github/workflows/rotate-hero.yml"
これで [skip ci] を忘れても、ヒーロー rotation の commit では test が走らなくなります。
設定上の注意点
権限まわり(→「必須セットアップ」)と schedule 変更直後の罠(→「cron 発火時刻の選び方」)は本文で扱ったので、ここでは残りの 3 つだけ。
1. 60 日 inactive でスケジュール停止
GHA は 60 日以上 commit がないリポジトリの scheduled workflow を勝手に止めます。動かなくなったと思ったら、リポジトリに commit を 1 個入れて再起動が必要。
2. プールが 1 枚だと回転しない
「現在の画像を除外して random pick」設計なので、画像が 1 枚しかないと候補ゼロでフォールバック → 結局同じ画像 → git diff --quiet で no-op、ということになります。最低でも 5〜10 枚を推奨。
3. HERO ブロックは 1 個だけ
複数 region で別々に rotate したい場合は、workflow の Python ロジックを分岐させてください。本テンプレは最初の HERO ブロックだけ swap します。

