GitHub ActionsでGoogleスプレッドシートを自動更新する仕組みの作り方|Apps Script Webhook連携

概要

今回はGitHub ActionsからGoogleスプレッドシートを自動更新する仕組みについて紹介していきます。

Markdownの原稿をgitにpushするだけで、Googleスプレッドシートの該当行が自動で追記・更新されるという構成です。

というのも、pushしたMDの情報を抽出して、スプレッドシートに貼るという面倒な手間をなくすために実装したものです。

認証はGoogle Apps ScriptのWebhookで完結させ、実行役はGitHub Actionsのランナーに任せています。

実装する中でハマったポイントや対処法もあわせてまとめます。

それではやっていきましょう!

目次

仕組みの全体像

まず全体の流れです。

  • 原稿はgitが正本、スプレッドシートは閲覧用ミラー
    • スプレッドシート側を直接編集してもgitには反映されない、一方向の同期にする
  • 認証はGoogle Apps Script Webhookで完結
    • GCPプロジェクトの作成やAPIキー管理が不要で、スプレッドシートのオーナーのGoogleアカウントだけで動く
  • 同期の実行役はGitHub Actionsのランナー
    • pushをトリガーに変更ファイルを検出し、スプレッドシートへ反映する

図にすると次のような構成です。

Googleスプレッドシート同期、どこでブロックされてどう回避したか
クラウド実行環境からApps Script Webhookへ直接POSTすると403でブロックされる一方、git pushでGitHub Actionsを起動しそこからPOSTすると成功する構成図

上段が最初に試して失敗した構成、下段が最終的に採用した構成です。

理由は後述しますが、pushはGitHubへの通信なので許可されるが、外部サービスへの直接通信は制限されているという制約があったため、GitHub Actionsに実行役を任せる形に変更したのです。

Google Apps Script側:Webhookを作る

認証方式は「Google Cloudサービスアカウント+Sheets API」ではなく、「Google Apps Script Webhook」を選びました。

理由は楽だからです(^^

シートに紐づけたApps Scriptを「ウェブアプリとしてデプロイ」すれば、そのURLにPOSTするだけで動きます。

doPostの基本形

doPost(e)で受け取ったJSONをパースし、secretフィールドをスクリプトプロパティの値と照合してから、日付列を検索して該当行を上書き(無ければ追記)します。

Code.gs

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
function doPost(e) {
const payload = JSON.parse(e.postData.contents);

const props = PropertiesService.getScriptProperties();
if (payload.secret !== props.getProperty('WEBHOOK_SECRET')) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: 'forbidden' }));
}

const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('drafts');
const dateCol = sheet.getRange('A:A').getValues();
const targetRow = dateCol.findIndex(row => normalizeDate(row[0]) === payload.date) + 1;

const row = [payload.date, payload.pillar, payload.body, payload.reply];

if (targetRow > 0) {
sheet.getRange(targetRow, 1, 1, row.length).setNumberFormat('@').setValues([row]);
} else {
const newRow = sheet.getLastRow() + 1;
sheet.getRange(newRow, 1, 1, row.length).setNumberFormat('@').setValues([row]);
}

return ContentService.createTextOutput(JSON.stringify({ ok: true }));
}

function normalizeDate(value) {
if (value instanceof Date) {
return Utilities.formatDate(value, 'JST', 'yyyy-MM-dd');
}
return value;
}

ハマりどころ:スプレッドシートの自動型変換で行が重複する

最初はUpsertロジックを単純な===比較で書いていました。

しかしGoogleスプレッドシートは"2026-01-01"という文字列を書き込むと、自動的にDate型に変換してしまうことがあります。

次回payload.date(文字列)とgetValues()で読み出した値(Dateオブジェクト)を比較すると一致せず、毎回「新規追記」判定になってしまい、同じ日付の行が何行も増えていきました。

  • 比較前に日付を正規化する
    • 値がDateオブジェクトならUtilities.formatDate(...)yyyy-MM-dd文字列へ揃えてから比較する
  • 書き込み時に表示形式をプレーンテキストに固定する
    • setNumberFormat('@')を毎回強制し、以後スプレッドシートが勝手にDate型へ変換しないようにする

先ほどのコード例は、すでにこの対策込みの最終版です。

Apps Scriptのコードを直しただけでは、既存のWebアプリURLには反映されません。
「デプロイを管理 > 編集 > バージョン:新しいバージョン > デプロイ」を毎回やり直す必要があります。
コードの保存とデプロイの反映は別物、というのは知らないとちょっと厄介です。

GitHub Actions側:pushをトリガーに同期する

ワークフローは、pushで変更されたMarkdownファイルを検出してから、Pythonスクリプトを呼ぶだけのシンプルな作りです。

.github/workflows/sheets-mirror.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
name: sheets-mirror

on:
push:
branches: [main]
paths:
- 'content/**/*.md'

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2

- name: Detect changed drafts
id: diff
run: echo "files=$(git diff --name-only HEAD^ HEAD -- 'content/*/*.md' | tr '\n' ' ')" >> "$GITHUB_OUTPUT"

- name: Sync to Google Sheets
if: steps.diff.outputs.files != ''
env:
SHEET_WEBHOOK_URL: ${{ secrets.SHEET_WEBHOOK_URL }}
SHEET_WEBHOOK_SECRET: ${{ secrets.SHEET_WEBHOOK_SECRET }}
run: python tools/google-sheets-webhook/sync_to_sheet.py ${{ steps.diff.outputs.files }}

sync_to_sheet.pyのポイント

sync_to_sheet.pyは、正規表現でMarkdownの表(| 柱 | ... |)とコードフェンス(本文・リプの中身)を抽出し、JSONペイロードを組み立ててWebhookへPOSTします。

実装のポイントは次の2つです。

  • 302リダイレクトを自前で1回フォローする
    • Apps ScriptのWebアプリURLはPOSTすると302リダイレクト先(script.googleusercontent.comにレスポンスを返すため、追従先へ素のGETを投げる必要がある
  • 日本語ペイロードはシェル引数を経由させない
    • JSONはjson.dumps(...).encode("utf-8")でPythonの中で組み立て、コマンドライン引数には日本語を一切通さない設計にする

sync_to_sheet.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import http.client
import json
from urllib.parse import urlparse

def post_with_redirect(url, payload):
parsed = urlparse(url)
conn = http.client.HTTPSConnection(parsed.netloc)
body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}

conn.request("POST", parsed.path + "?" + parsed.query, body, headers)
res = conn.getresponse()

if res.status in (301, 302, 303):
location = res.getheader("Location")
redirected = urlparse(location)
conn2 = http.client.HTTPSConnection(redirected.netloc)
conn2.request("GET", redirected.path + "?" + redirected.query)
res = conn2.getresponse()

return json.loads(res.read())
curlで動作確認する場合も考え方は同じです。
まずPOSTしてレスポンスヘッダーの`Location`を取得し、そのURLへ改めてGETを投げる、という2段階を手動で行うのが一番安全です。
`curl -L`の自動追従は`411 Length Required`エラーになるので使えません。

なぜGitHub Actions経由にしたのか

最初はGitHub Actionsを挟まず、日次の自動ルーティン(Claude Codeのクラウド実行環境)から直接Webhookへcurl POSTしていました。

しかしこの環境からscript.google.comへ通信しようとすると、403で拒否され続けました。

クラウドで自律的に動くエージェントの実行環境は、セキュリティ上の理由で任意の外部サービスへの通信が絞られていることがあるらしく、GitHubなど許可されたホスト以外へは届かない仕組みのようです(詳細な仕様は非公開です)。

そこで、通信の実行役を権限の強い別の実行環境に委譲することにしました。

  • 日次ルーティンはGitHubへのpushだけ担当する
    • ここはGitHubへの通信なので普通に許可されている
  • スプレッドシートへの通信はGitHub Actionsのランナーに任せる
    • GitHub Actionsのランナーは通常の外部通信を行えるため、Webhookに問題なく届く

Webhook(Apps Script)側のコードは変更なしで、呼び出し元をクラウド実行環境からGitHub Actionsに差し替えるだけで動きました。

ネットワーク制限のあるサンドボックスから、より自由な実行環境(CI/CD基盤)へ「最後の一手」だけ委譲する回避パターンですね。

秘密情報の管理

Webhookの合言葉(SECRET)はApps Script側の「スクリプトプロパティ」に保存し、コード自体には書いていません。

URLとSECRETはgitリポジトリにも一切コミットせず、GitHubリポジトリのSecrets(Settings > Secrets and variables > Actions)に保存しています。

ワークフローYAMLからは${{ secrets.SHEET_WEBHOOK_URL }}のように参照するだけなので、値そのものがログに残ることもありません。

最終的なファイル構成は次の通りです。

ファイル構成

1
2
3
4
5
.github/workflows/sheets-mirror.yml   # push契機のワークフロー本体
tools/google-sheets-webhook/
Code.gs # Google Apps Script(Webhook本体)
sync_to_sheet.py # Markdownドラフト→スプレッドシート用JSONペイロードへの変換+POST処理
README.md # セットアップ手順書

まとめ

pushをトリガーに、GitHub Actions経由でGoogleスプレッドシートを自動更新する仕組みを紹介しました。

通信の実行役を権限の強い別の実行環境に委譲するという設計判断がポイントで、Webhook自体の作りはシンプルなまま使い回せます。

curlのリダイレクト追従・文字化け・日付の型変換という3つの落とし穴も、同じような構成を組む方には参考になるはずです。

以上となります。

GitHub Actionsで大抵のやりたいことは解決できることを最近知りました(^^

それではお疲れさまでした。