田中力磨 › 一人塾のGAS自動化 実録 › clasp自動デプロイ

GASをclaspとGitHub Actionsで自動デプロイする——バージョン上限200と夜間停止

mainにマージすればGASへ自動で反映される。版は必要なときだけ1つ作り、夜は本番に出さず、止めた分は翌朝に拾い直す仕組みの記録。

2026-10-19 公開 / 滋賀県彦根市の一人塾「総合学習教室ブリッジ」で実際に動いている仕組みの記録 / Google Apps Script(GAS)

この記事は、教室で動いているコードと運用の記録をもとに、AI(Claude)が書きました。

何を自動化したか

GASのコードをGitHubで管理しても、本番への反映は別の作業として残る。エディタへの貼り付けか手元での clasp push が要り、忘れればGitHubと本番のコードが食い違う。この教室でも、エディタでの直接編集は二重管理の事故のもととして避ける方向だ。

今は main にマージすると、GitHub Actions の deploy-gas.yml が clasp で本体のGASへコードを送る。Webアプリ関連のファイルが変わったときだけ版(バージョン)を1つ作って既存のデプロイを切り替えるので、公開URLは変わらない。夜23時〜朝7時(日本時間)は反映を止め(この教室では「カーフュー」と呼ぶ)、止めた分は翌朝 curfew-catchup.yml が拾い直す。版は1プロジェクト200個までなので、「必要なときだけ、1回に1つ」が設計の中心だ。

仕組み(データの流れ)

いつ動くもの何をするか
main へのマージ(.gs・Webアプリ用HTMLなどの変更)deploy-gas.yml の curfew ジョブ日本時間23時〜7時なら失敗させて止める
続けて同 deploy ジョブSecrets から ~/.clasprc.json を作り、clasp push --force で送る
Webアプリ関連の変更時だけ同 版作成ステップ版を1つ作り、番号付きの既存デプロイをすべてその版へ切り替える
毎朝7:15curfew-catchup.yml の rerun最新のデプロイ実行が失敗なら再実行する
毎朝7:45同 heartbeat?app=health で、自動化が26時間以内に動いたかを確かめる
手動(Run workflow)deploy-gas.ymlカーフューを通過し、差分に関係なく版を作る

版がWebアプリにだけ要るのは、トリガーで動く処理は push された最新のコード(HEAD)を実行するが、Webアプリの番号付きデプロイは特定の版に固定されているからだ。

同じワークフローは、サブフォルダの別のGASプロジェクト(生徒管理のバックエンド)も、そのフォルダの .clasp.json で送る。7:45のハートビートはトリガー集約の記事で書いた外部監視で、再デプロイ直後の誤報を避けるため7:15から30分ずらしてある。

コード(要点)

deploy-gas.yml は、時刻を調べる curfew ジョブを先に置き、通ったときだけ deploy ジョブが動く(needs: curfew)。

jobs:
  # …(省略)…
  curfew:
    runs-on: ubuntu-latest
    steps:
      - name: 時間帯チェック(JST 23時〜7時は停止。手動実行・[deploy-now]は解除扱い)
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            # …(省略)…
            exit 0
          fi
          # …(省略)…
          HOUR=$(TZ=Asia/Tokyo date +%-H)
          if [ "$HOUR" -ge 23 ] || [ "$HOUR" -lt 7 ]; then
            # …(省略)…
            exit 1
          fi
          # …(省略)…

  deploy:
    needs: curfew
    # …(省略)…
      - name: Install clasp
        run: npm install -g @google/clasp

      - name: Validate and apply CLASPRC_JSON
        run: |
          SECRET='${{ secrets.CLASPRC_JSON }}'
          if ! echo "$SECRET" | python3 -m json.tool > /dev/null 2>&1; then
            # …(省略)…
            exit 1
          fi
          echo "$SECRET" > ~/.clasprc.json
      # …(省略)…
      - name: Push to GAS
        run: clasp push --force && echo "✅ GASへのデプロイ完了"

夜間は exit 1 でわざと失敗させ、翌朝の再実行の目印にする。認証は、手元で clasp login するとできる ~/.clasprc.json の中身を Secrets の CLASPRC_JSON に丸ごと登録し、実行のたびに書き戻す。JSONとして読めない値なら、そこで止まる。

~/.clasprc.json は、そのGoogleアカウントとして clasp を動かせる認証情報だ。.gitignore に入れてコミットせず、Secrets にだけ置く。

この抜粋は、Secrets の値を ${{ secrets.CLASPRC_JSON }} でスクリプトに直接埋め込んでいる。GitHub のドキュメントは、秘密の値を env: で環境変数として渡し、引用符で囲んで使う形を示している(GitHub Actions でのシークレットの使用)。これから作るなら、そちらの形がよい。

同じく GitHub は、JSON のような構造化データをシークレットの値にしないよう求めている。ログの伏せ字は値の完全一致を探して行うため、構造化データでは伏せ字が効かないことがあるからだ(セキュリティで保護された使用に関するリファレンス)。この教室は ~/.clasprc.json を丸ごと1つのシークレットにしているので、その値をログに出す処理は書かず、ファイルへの書き出しだけにしている。

.clasp.json は "rootDir": "." でリポジトリ全体を対象にしている。そのため .gs・.html・appsscript.json は、除外しない限りすべて送られる。送らないものは .claspignore に書く。

# ブラウザ用ファイル(GASに送らない)
# 注意: claspのignoreはglob。ディレクトリは「dir/**」形式でないと配下が除外されない
app.js
style.css
index.html
data/**
README.md
CLAUDE.md
docs/**
.github/**
.claude/**
node_modules/**
public-site/**
# …(省略)…
# 生徒管理バックエンド(別のGASプロジェクト。本体GASへは送らない。appsscript重複エラー防止)
student-backend/**
# …(省略)…

除外はファイルのパスに対するパターン照合なので、docs では配下が外れず、docs/** と書く。サブフォルダの別プロジェクトも、ここで外さないと自分の appsscript.json ごと本体に混ざる。

版を作るかどうかは、前のステップ(webdiff)が直前のコミットとの差分(git diff --name-only HEAD^ HEAD)から決める。直前のコミットが要るので、checkout は fetch-depth: 2 にしてある。

以前は、変わったファイルがWebアプリ関連のフォルダ(webapp/・lp/ など)にあるかだけで決めていた。ところが、Webアプリの版で動くコードの一部は一覧にないフォルダにもあり、そこだけを変えたときは版が作られず、Webアプリ側では古いコードが動き続けていた(2026-09-27に直した)。今は判定を小さなシェルスクリプト(tools/ci/webapp-version-needed.sh)に切り出し、そうしたファイルが変わったときも版を作る。それ以外の共通関数などの変更では版を作らず、Webアプリには次に版が作られたときに反映される。

- name: Deploy web app (新バージョンを既存デプロイに反映)
  # 手動実行(workflow_dispatch)は「版の反映し直し」が目的なので常に版を作る
  if: steps.webdiff.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
  run: |
    LIST=$(clasp deployments 2>&1) || true
    # …(省略)…
    IDS=$(echo "$LIST" | grep -oE '\bAKfycb[A-Za-z0-9_-]+ @[0-9]+' | awk '{print $1}')
    if [ -z "$IDS" ]; then
      # …(省略)…
    else
      # バージョン上限(200)対策: 1回の実行で作る版は1つだけ。
      # まず新しい版を1つ作り、その同じ版番号を全デプロイに反映する。
      VOUT=$(clasp version "auto-deploy $(date -u +%FT%TZ)" 2>&1) || true
      echo "$VOUT"
      if echo "$VOUT" | grep -qi 'limit of 200 versions'; then
        # …(省略)…
        exit 1
      fi
      # 「Created version 12.」形式からのみ版番号を取る(エラーメッセージ中の数字を拾わない)
      VNUM=$(echo "$VOUT" | grep -ioE 'version [0-9]+' | grep -oE '[0-9]+' | head -1)
      for ID in $IDS; do
        if [ -n "$VNUM" ]; then
          RES=$(clasp deploy -i "$ID" -V "$VNUM" 2>&1) || true
        # …(省略)…
      done
    fi

@HEAD(常に最新のコードを指すテスト用デプロイ)を除いた番号付きのデプロイだけを拾い、先に版を1つ作って、その番号を -V で全デプロイに渡す。clasp deploy -i は -V を省くと呼ぶたびに版を作るので、デプロイの数だけ版を使ってしまうからだ。

翌朝の拾い直しは curfew-catchup.yml が actions/github-script で行う。cron はUTCで書くので7:15は '15 22 * * *' になり、7:45の回とはジョブの if: github.event.schedule で振り分ける。権限には actions: write を付けている。

const workflows = ['deploy-gas.yml'];
for (const wf of workflows) {
  try {
    const res = await github.rest.actions.listWorkflowRuns({
      owner: context.repo.owner, repo: context.repo.repo,
      workflow_id: wf, branch: 'main', per_page: 1,
    });
    const run = res.data.workflow_runs[0];
    // …(省略)…
    if (run.conclusion === 'failure') {
      // …(省略)…
      await github.rest.actions.reRunWorkflow({
        owner: context.repo.owner, repo: context.repo.repo, run_id: run.id,
      });
    } else {
      // …(省略)…
    }
  } catch (e) {
    // …(省略)…
  }
}

再実行は元と同じコミットで走るので、差分の判定も当時のままになる。

ハマった点と回避策

版の上限200に実際に当たった

本体は一度、版が200個に達して新しい版を作れなくなった。このときは更新の多い家計簿アプリを新しい別プロジェクト(版は0から数え直し)へ分け、のちに本体では家計簿の変更で版を作らないよう変更検出からも外した。上限に当たっても、トリガーで動く処理は push だけで最新になるので、止まるのはWebアプリの更新だけだ。

止まった回の変更が、次のデプロイの差分に入らなかった

版の作成と別プロジェクトへの送信は、直前のコミットとの差分でしか判定しない。カーフューで止まった回のあとに別のコミットのデプロイが走ると、止まった回の変更はどの差分にも入らず、最新の1件しか見ない朝の再実行でも拾えない。2026-07-29には実際に、生徒管理のバックエンドへの変更が本番に届かないまま残った。今は手動実行なら差分に関係なく版を作り別プロジェクトにも送るので、取り残しは手動実行でそろう。

朝7:15の再実行は、時刻どおりに動くとは限らない

GitHub Actions の schedule は遅れることがある。同じ仕組みを置く公開サイト側のリポジトリでは、7:15の予定が実際には9:00〜9:20ごろだった。急ぐなら手動実行だが、この教室のAIが使うGitHub App経由の連携からは手動実行が403で拒否された。そこで2026-07-29から、筆者が承認した夜間デプロイに限り、マージコミットのメッセージに [deploy-now] を含めればカーフューを通過できるようにした。再実行も同じく403になると、あとで分かった。

導入手順

  1. clasp を使うGoogleアカウントで Apps Script API をオンにし(Apps Script の設定画面)、手元で npm install -g @google/clasp と clasp login を実行する(claspの公式ガイド)。~/.clasprc.json ができる
  2. リポジトリ直下に .clasp.json を置く。この教室の中身は {"scriptId": "<スクリプトID>", "rootDir": ".", "scriptExtensions": [".gs"]} に、既定値のままの項目(HTMLとJSONの拡張子など)を足したものだ(スクリプトIDはGASエディタの「プロジェクトの設定」にある)。scriptExtensions を書かないと既定の [".js", ".gs"] になり、手元の .js もGASのスクリプトとして送られる。.gitignore には .clasprc.json を足す。すでにファイルのあるフォルダで clasp clone を実行すると、GAS側のファイルが既定では .js の名前で書き出され、手元の .gs と二重になるので、使うなら空のフォルダで実行してできた .clasp.json だけを移す
  3. ~/.clasprc.json の中身を、GitHubの Secrets に CLASPRC_JSON として登録する(gh secret set CLASPRC_JSON --body "$(cat ~/.clasprc.json)" でもよい)
  4. .claspignore に送らないファイルを dir/** の形で書き、clasp status で送られる一覧を確かめる
  5. ワークフローを置く。push を main と .gs などの paths に絞り、checkout は fetch-depth: 2、clasp のインストール、~/.clasprc.json の書き出し、clasp push --force の順にする
  6. 権限の承認とトリガーの設定は、GASエディタで関数を1回実行して済ませる
  7. Webアプリがあれば、版を1つ作って番号付きの全デプロイへ渡すステップを足す
  8. 夜間停止には、時刻を見て exit 1 する関門のジョブと、翌朝に最新の失敗を再実行するスケジュール用のワークフローを足す

よくある質問

Q. トリガーの設定もCIから実行できますか

この教室では、デプロイ後に clasp run でトリガーの設定関数を呼んでいたが、CIからは権限不足で動かないと実測で分かり(2026-07-17)、削除した。再設定は、オーナー権限で毎日動くGAS内の自己点検に任せている(トリガー集約の記事)。

Q. 夜中にどうしても本番へ出したいときは、どうしますか

Actions画面から手動実行(Run workflow)すれば、カーフューを通過する。夜間に止めるのは、マージのたびに走る自動デプロイだけだ。

Q. 版が200個に達したら、どうすればよいですか

版はAPIからは削除できない。公式ドキュメントでは、稼働中のデプロイに使われていない版を、GASエディタの「プロジェクト履歴」から削除できるとされている(版の説明)。不要なデプロイを先にアーカイブすれば、その版も削除の対象になる。

筆者の本とnote(有料)で、まとめて読む