iPhone から GitHub Issue を書くだけで Mac の Claude Code が自動実行される仕組みを作った

TL;DR

GitHub Self-hosted Runner + Claude Code CLI を組み合わせて、iPhone で Issue を作成 → Mac 上の Claude Code が自動で調査・コード修正・PR 作成まで行う仕組みを構築した。2ステージ承認フロー付きで、/claude で調査、/approve で実行という安全な運用が可能。


モチベーション

普段 Mac の前にいないときでも、iPhone からコードの調査や修正を指示したい。GitHub Issue に書くだけで Claude Code が自動的に動いてくれれば、移動中でもコードレビューや修正依頼ができる。

完成した仕組み

iPhone                     GitHub                    Mac (self-hosted runner)
  │                          │                          │
  ├─ Issue 作成 ────────────→│                          │
  │  (claude ラベル付与)      │── ワークフロー起動 ─────→│
  │                          │                          ├─ claude -p (読み取り専用)
  │                          │←── 調査結果コメント ──────┤
  │                          │    + 承認待ちラベル       │
  │                          │                          │
  ├─ /approve コメント ──────→│                          │
  │                          │── ワークフロー起動 ─────→│
  │                          │                          ├─ claude -p (書き込み許可)
  │                          │←── 実行結果 + PR ────────┤
  │                          │    - 承認待ちラベル       │

トリガー一覧

操作条件動作
claude ラベル付き Issue 起票起票者がリポジトリオーナーStage 1: 読み取り専用で調査
/claude コメントコメント者がリポジトリオーナーStage 1: 読み取り専用で調査
/claude --execute コメントコメント者がリポジトリオーナーStage 1 スキップ、直接実行
/approve コメントコメント者がリポジトリオーナー + 承認待ち ラベルStage 2: 計画に基づき実行

必要なもの

  • macOS マシン (Apple Silicon / Intel)
  • Claude Code CLI がインストール済み (npm install -g @anthropic-ai/claude-code)
  • Claude Max/Pro プラン (OAuth トークン) または Anthropic API キー
  • GitHub CLI (gh)

セットアップ手順

1. Self-hosted Runner のインストール

GitHub の個人アカウントでは Runner はリポジトリ単位の登録になる。同一マシンに複数の Runner を並置できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ディレクトリ作成
mkdir -p ~/actions-runner/my-repo
cd ~/actions-runner/my-repo

# Runner パッケージのダウンロード (GitHub Settings > Actions > Runners で最新 URL を確認)
curl -o actions-runner-osx-arm64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.331.0/actions-runner-osx-arm64-2.331.0.tar.gz
tar xzf actions-runner-osx-arm64.tar.gz

# 登録トークンの取得
TOKEN=$(gh api repos/OWNER/REPO/actions/runners/registration-token -X POST --jq '.token')

# Runner の設定 (名前は任意)
./config.sh --url https://github.com/OWNER/REPO --token $TOKEN --name mac-runner --unattended

# macOS の LaunchAgent として常駐化 (ログイン時に自動起動)
./svc.sh install
./svc.sh start

2つ目以降のリポジトリは、tarball をコピーして同様に設定すればよい:

1
2
3
4
5
mkdir -p ~/actions-runner/another-repo
cd ~/actions-runner/another-repo
cp ~/actions-runner/my-repo/actions-runner-osx-arm64.tar.gz .
tar xzf actions-runner-osx-arm64.tar.gz
# 以降同じ手順

2. Claude Code の認証トークンを登録

Max/Pro プランの場合 (推奨):

1
2
3
4
5
# ブラウザ認証でOAuth トークンを生成 (有効期限: 1年)
claude setup-token

# 表示されたトークンを GitHub Secret に登録
gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo OWNER/REPO

API キーの場合:

1
gh secret set ANTHROPIC_API_KEY --repo OWNER/REPO

注意: Max プランで ANTHROPIC_API_KEY を設定しようとすると、シェルに環境変数がないため空で登録されてしまう。Max/Pro プランでは必ず claude setup-token を使うこと。

3. ラベルの作成

1
2
gh label create claude --color 7C3AED --description "Claude Code 自動処理" --repo OWNER/REPO
gh label create 承認待ち --color FCD34D --description "Claude Code 実行承認待ち" --repo OWNER/REPO

4. ワークフローファイルの配置

.github/workflows/claude-issue.yml をリポジトリに追加する。

claude-issue.yml (全文)
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
name: Claude Code on Issue

on:
  issues:
    types: [labeled]
  issue_comment:
    types: [created]

jobs:
  claude-work:
    # リポジトリオーナーのみ実行可能
    if: >-
      (
        github.event_name == 'issues' &&
        contains(github.event.issue.labels.*.name, 'claude') &&
        github.event.issue.user.login == github.repository_owner
      ) || (
        github.event_name == 'issue_comment' &&
        github.event.comment.user.login == github.repository_owner &&
        (startsWith(github.event.comment.body, '/claude') || startsWith(github.event.comment.body, '/approve'))
      )
    runs-on: self-hosted
    permissions:
      contents: write
      issues: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - name: Detect user home
        id: env
        run: |
          echo "home=$(eval echo ~$(whoami))" >> "$GITHUB_OUTPUT"

      - name: Determine mode
        id: mode
        uses: actions/github-script@v7
        with:
          script: |
            const event = context.eventName;
            let mode = 'investigate';

            if (event === 'issue_comment') {
              const body = context.payload.comment.body.trim();

              if (body.startsWith('/approve')) {
                const labels = context.payload.issue.labels.map(l => l.name);
                if (!labels.includes('承認待ち')) {
                  core.setFailed('承認待ちラベルがありません。先に /claude で調査を実行してください。');
                  return;
                }
                mode = 'execute';
              } else if (body.startsWith('/claude')) {
                const args = body.replace('/claude', '').trim();
                if (args === '--execute') {
                  mode = 'execute';
                } else {
                  mode = 'investigate';
                }
              }
            } else {
              mode = 'investigate';
            }

            core.setOutput('mode', mode);
            console.log(`Mode: ${mode}`);

      - name: Build prompt
        id: prompt
        uses: actions/github-script@v7
        with:
          script: |
            const issue = context.payload.issue;
            const mode = '${{ steps.mode.outputs.mode }}';
            let prompt = `GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}`;

            if (context.eventName === 'issue_comment') {
              const comment = context.payload.comment.body.trim();
              let instruction = '';

              if (comment.startsWith('/approve')) {
                instruction = comment.replace('/approve', '').trim();
                const comments = await github.rest.issues.listComments({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: issue.number,
                });
                const planComment = comments.data
                  .reverse()
                  .find(c => c.body.includes('Claude Code 調査結果'));
                if (planComment) {
                  prompt += `\n\n## 承認済み計画\n\n${planComment.body}`;
                }
              } else {
                instruction = comment.replace('/claude', '').replace('--execute', '').trim();
              }

              if (instruction) {
                prompt += `\n\n追加指示: ${instruction}`;
              }
            }

            if (mode === 'investigate') {
              prompt += '\n\n## 指示\n';
              prompt += '読み取り専用モードです。コードの調査と分析のみ行ってください。\n';
              prompt += '以下を出力してください:\n';
              prompt += '1. 問題の分析結果\n';
              prompt += '2. 変更が必要なファイルの一覧\n';
              prompt += '3. 実行予定の変更内容\n';
              prompt += '4. 作成予定の PR の概要\n';
            } else {
              prompt += '\n\n## 指示\n';
              prompt += '書き込み許可モードです。計画に基づきファイル変更・コミット・PR 作成を実行してください。\n';
              prompt += '作業が完了したら結果のサマリを出力してください。\n';
            }

            const fs = require('fs');
            fs.writeFileSync('/tmp/claude-prompt.txt', prompt);

      - name: Run Claude Code
        env:
          CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          HOME: ${{ steps.env.outputs.home }}
          PATH: ${{ steps.env.outputs.home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
        run: |
          MODE="${{ steps.mode.outputs.mode }}"

          if [ "$MODE" = "investigate" ]; then
            TOOLS="Read,Glob,Grep"
          else
            TOOLS="Bash,Read,Write,Edit,Glob,Grep"
          fi

          claude -p "$(cat /tmp/claude-prompt.txt)" \
            --allowedTools "$TOOLS" \
            --output-format json > /tmp/claude-result.json 2>/tmp/claude-stderr.log || {
              echo "Claude Code failed. Stderr:"
              cat /tmp/claude-stderr.log 2>/dev/null
              echo "Stdout:"
              cat /tmp/claude-result.json 2>/dev/null || echo "(no output file)"
              exit 1
            }

      - name: Post result to issue
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const mode = '${{ steps.mode.outputs.mode }}';
            const raw = fs.readFileSync('/tmp/claude-result.json', 'utf8');
            const result = JSON.parse(raw);
            const body = result.result || result.text || JSON.stringify(result, null, 2);

            const header = mode === 'investigate'
              ? '## Claude Code 調査結果'
              : '## Claude Code 実行結果';
            const footer = '---\n🤖 自動実行 by Claude Code (self-hosted runner)';

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.issue.number,
              body: `${header}\n\n${body}\n\n${footer}`
            });

            if (mode === 'investigate') {
              try {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: context.payload.issue.number,
                  labels: ['承認待ち']
                });
              } catch (e) {
                console.log(`ラベル付与エラー: ${e.message}`);
              }
            }

            if (mode === 'execute') {
              try {
                await github.rest.issues.removeLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: context.payload.issue.number,
                  name: '承認待ち'
                });
              } catch (e) {
                console.log(`ラベル除去エラー: ${e.message}`);
              }
            }

ハマったポイントと解決策

実際に構築する中で遭遇した問題を全て記録する。これが一番参考になるはず。

1. claude コマンドが見つからない

症状: Runner のジョブで claude: command not found

原因: Self-hosted Runner は macOS の LaunchAgent として動作するが、launchd サービスはユーザーの .zshrc / .bashrc を読まない。そのため ~/.local/bin が PATH に含まれない。

解決: ワークフローの env で明示的に PATH を指定する。

1
2
3
env:
  HOME: ${{ steps.env.outputs.home }}
  PATH: ${{ steps.env.outputs.home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin

HOME も同様に上書きが必要。GitHub Actions Runner は作業ディレクトリを HOME に設定するため、Claude Code が設定ファイルを見つけられなくなる。

2. Issue 起票で2回実行される (二重発火)

症状: 1つの Issue を作成しただけなのに、ワークフローが2回走る

原因: on.issues.types: [opened, labeled] と設定していたため、claude ラベル付きで Issue を起票すると openedlabeled の両方のイベントが発火する。

解決: トリガーを [labeled] のみに変更。

1
2
3
on:
  issues:
    types: [labeled]  # opened を含めない

ラベルなしで起票 → 後からラベルを付ける運用でも、ラベル付きで直接起票する運用でも、labeled イベント1回だけが発火する。

3. Max プランで認証エラー (Not logged in)

症状: "Not logged in · Please run /login" エラーで Claude Code が動かない

原因: Claude Max/Pro プランでは ANTHROPIC_API_KEY 環境変数は使わない。OAuth ログインで認証しているため、シェルに API キーが存在しない。gh secret set ANTHROPIC_API_KEY を実行しても空文字が登録される。

解決: claude setup-token で CI/CD 用の長期 OAuth トークンを生成する。

1
2
3
claude setup-token
# ブラウザが開く → 認証 → トークンが表示される
# このトークンを GitHub Secret CLAUDE_CODE_OAUTH_TOKEN に登録

ワークフロー側は ANTHROPIC_API_KEY ではなく CLAUDE_CODE_OAUTH_TOKEN を使う:

1
2
env:
  CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

setup-token のトークンは1年間有効claude login の対話的セッションとは異なる。

4. JSON パースエラー (stderr 混入)

症状: Post result to issue ステップで SyntaxError: Unexpected token ⚠ in JSON

原因: claude -p が stderr に警告メッセージ (⚠️ [BashTool]...) を出力する。2>&1 でリダイレクトしていたため、JSON 出力に警告文字列が混入し JSON.parse() が失敗。

解決: stderr を別ファイルにリダイレクトする。

1
2
3
4
5
# NG: stderr が stdout に混入
claude -p "..." --output-format json > /tmp/result.json 2>&1

# OK: stderr を別ファイルに分離
claude -p "..." --output-format json > /tmp/result.json 2>/tmp/stderr.log

失敗時のデバッグのために stderr も保存しておくと便利。

5. runner.home が存在しない

症状: PATH が /.local/bin:/opt/homebrew/bin:... になり、先頭にホームディレクトリがない

原因: GitHub Actions のコンテキストに runner.home は存在しない。${{ runner.home }} と書くと空文字に展開される。

解決: 別ステップで whoami を使ってホームディレクトリを取得する。

1
2
3
4
5
6
7
8
9
- name: Detect user home
  id: env
  run: |
    echo "home=$(eval echo ~$(whoami))" >> "$GITHUB_OUTPUT"

- name: Run Claude Code
  env:
    HOME: ${{ steps.env.outputs.home }}
    PATH: ${{ steps.env.outputs.home }}/.local/bin:...

6. gh secret get でシークレットを読めない

症状: 1つのリポジトリに設定した Secret を他のリポジトリにコピーしようとして失敗

原因: GitHub の仕様上、一度登録した Secret の値は読み取れない。gh secret get コマンドは存在しない。

解決: 各リポジトリで個別に gh secret set を実行する。同じトークンを繰り返し貼り付ける必要がある。

1
2
3
4
5
# 全リポジトリに同じトークンを登録
for repo in repo1 repo2 repo3; do
  gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "OWNER/$repo"
done
# → 毎回トークンの入力を求められる

運用ノート

Runner の起動・停止

Runner は macOS の LaunchAgent (~/Library/LaunchAgents/actions.runner.*.plist) として動作する。

1
2
3
4
5
6
cd ~/actions-runner/my-repo

./svc.sh status    # 状態確認
./svc.sh stop      # 一時停止 (再起動で自動復帰)
./svc.sh start     # 起動
./svc.sh uninstall # 完全除去 (再起動しても起動しない)
操作Mac 再起動後
stop自動で再起動する (plist が残っているため)
uninstall起動しない

Mac 再起動時はログインが必要 (LaunchAgent はユーザーセッション開始後に起動)。自動ログインを有効にしておくと安心。

Runner が停止中に Issue を作ったら?

ワークフローのジョブは queued 状態で最大6時間待機する。Runner が復帰すればキューのジョブが実行される。6時間を超えるとキャンセル。

OAuth トークンの有効期限

認証方式有効期限
claude setup-token1年間
claude login (対話的)8〜12時間
ANTHROPIC_API_KEY無期限 (従量課金)

月次ヘルスチェック

トークンの期限切れを事前に検知するため、月1回のヘルスチェックワークフローを追加するのがおすすめ:

claude-health-check.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
name: Claude Code Health Check

on:
  schedule:
    - cron: '0 9 1 * *'  # 毎月1日 9:00 UTC

jobs:
  check:
    runs-on: self-hosted
    steps:
      - name: Detect user home
        id: env
        run: echo "home=$(eval echo ~$(whoami))" >> "$GITHUB_OUTPUT"

      - name: Check Claude Code auth
        env:
          CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          HOME: ${{ steps.env.outputs.home }}
          PATH: ${{ steps.env.outputs.home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
        run: |
          result=$(claude -p "Reply with only: OK" --output-format json 2>/dev/null)
          if echo "$result" | grep -q '"is_error":true'; then
            echo "::error::Claude Code 認証エラー — CLAUDE_CODE_OAUTH_TOKEN の更新が必要です"
            echo "更新手順: claude setup-token を実行し、gh secret set CLAUDE_CODE_OAUTH_TOKEN で登録"
            exit 1
          fi
          echo "Claude Code 認証OK"

失敗するとGitHub からメール通知が届く。最小プロンプト ("Reply with only: OK") なのでコストはほぼゼロ。

トークン更新手順

1
2
3
4
5
6
7
# 1. 新しいトークンを生成 (ブラウザ認証)
claude setup-token

# 2. 全リポジトリの Secret を更新
for repo in repo1 repo2 repo3; do
  gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "OWNER/$repo"
done

セキュリティ

実行者の制限

github.repository_owner との比較により、リポジトリオーナーのみがワークフローを起動できる。第三者が claude ラベルを付けたり /claude コメントしてもジョブは実行されない。

ツール制限

ステージ許可ツールリスク
Stage 1 (調査)Read, Glob, Grepファイル読み取りのみ。変更不可
Stage 2 (実行)Bash, Read, Write, Edit, Glob, Grepファイル変更・コマンド実行可能

Bash をさらに制限したい場合はパターン指定が可能:

1
--allowedTools "Bash(git *),Bash(npm run *),Read,Write,Edit,Glob,Grep"

Self-hosted Runner のリスク

Self-hosted Runner は自分のマシン上で任意のコードを実行する。パブリックリポジトリでの利用は推奨されない (他者の PR がトリガーになりうる)。プライベートリポジトリ、または今回のようにオーナー制限付きのワークフローで使うのが安全。


コスト

項目コスト
GitHub Actions (self-hosted)無料 (自分のマシンを使うため)
Claude Code (Max プラン)月額のプラン料金に含まれる
Claude Code (API キー)実行量に応じた従量課金
ヘルスチェック月1回、最小プロンプトなので実質ゼロ

まとめ

  • 構築時間: 半日程度 (デバッグ含む)
  • 最大のハマりポイント: launchd の PATH 問題と Max プランの OAuth 認証
  • 最も価値があること: iPhone から Issue 1つ書くだけで、Mac の Claude Code がコードを読んで分析・修正・PR 作成まで自動でやってくれる
  • 2ステージ承認: 読み取り専用で調査 → 計画を確認 → 承認して実行、という流れで安全性を確保

同じ仕組みを作りたい方は、ワークフローファイルをコピーして、Runner のインストールと Secret の設定だけで動かせます。ハマりポイントの解決策は上に全て書いたので、同じ轍を踏まずに済むはず。


Built with Claude Code on macOS (Apple Silicon)