ちょっと話題の記事

TypeScript で記述した Google Apps Script を clasp と GitHub Actions を使ってデプロイする

TypeScript で記述した Google Apps Script を clasp と GitHub Actions を使ってデプロイし、トリガーを使った定期実行をしてみました。
2021.04.13

@google/clasp を使うことで CLI で Google Apps Script (GAS) を扱えるため、コードを Git で管理できるようになります。

今回はコードを GitHub で管理し、テストと clasp push を Github Actions で実行できるようにしてみます。

最終的な完成物は下記のリポジトリになります。

(環境変数を設定するところを間違えてハマってしまったため、変なタグがいっぱいあります……)

インストール

インストールは公式の手順に従ってください。

$ npm install -g @google/clasp
$ exec $SHELL -l
$ clasp -v
2.3.0

Google Apps Script API が有効でなければ有効化しておく必要があります。おそらく初期では無効になっているかと思います。

有効になっていない場合、以下のようなエラーが表示されます。

GaxiosError: User has not enabled the Apps Script API. Enable it by visiting https://script.google.com/home/usersettings then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

プロジェクトを作成する

プロジェクトの作成は clasp create で行います。

$ mkdir clasp-github-actions-example && cd $_
$ clasp create --type standalone
Could not read API credentials. Are you logged in globally?

初めて作成する場合には、credential となる .clasprc.json が作成されていないため、上記のようにエラーとなります。

その場合には clasp login でログインし、~/.clasprc.json が作成します。

Default credentials saved to: ~/.clasprc.json (/Users/YOUR_NAME/.clasprc.json).

初めてのログイン時には上記のように権限の許可が必要になります。

あらためて clasp create してみましょう。

$ clasp create --type standalone
Created new standalone script: https://script.google.com/d/xxxxxxxx/edit
Warning: files in subfolder are not accounted for unless you set a '.claspignore' file.
Cloned 1 file.
└─ appsscript.json

TypeScript 対応

clasp は v1.5.0 以降、 TypeScript のままでも自動で変換してくれるようになりました。

まずは型定義を追加します。

yarn add -D @types/google-apps-script

次に tsconfig.json を追加しましょう。下記の設定は公式ドキュメントの記載されているものに、"strict": true を追加したものです。

// tsconfig.json
{
  "compilerOptions": {
    "lib": ["esnext"],
    "experimentalDecorators": true,
    "strict": true
  }
}

実際にデプロイするコードは、ここでは公式ドキュメントのものをそのまま使用していきます。

// src/hello.ts
const greeter = (person: string) => {
  return `Hello, ${person}!`;
};

function testGreeter() {
  const user = "Grant";
  Logger.log(greeter(user));
}

ESLint と Prettier の導入

ESLint と Prettier も導入しておきましょう。

yarn add -D eslint prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-plugin-import

ここでは Prettier はデフォルトの設定を使用するため、設定ファイルは追加しません。.eslint.js のみ追加しています。

.eslint.js
module.exports = {
  extends: ["eslint:recommended"],
  env: {
    browser: true,
    node: true,
    es6: true,
  },
  parserOptions: {
    ecmaVersion: 2020,
  },
  overrides: [
    {
      files: ["**/*.ts"],
      extends: [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "prettier",
      ],
      parser: "@typescript-eslint/parser",
      parserOptions: {
        sourceType: "module",
        project: "./tsconfig.json",
      },
      plugins: ["@typescript-eslint", "import"],
      rules: {
        "no-unused-vars": "off",
        "import/order": "error",
        "@typescript-eslint/no-unused-vars": [
          "error",
          { varsIgnorePattern: "testGreeter" },
        ],
      },
    },
  ],
};

エントリポイントとなる関数に no-unused-vars のエラーが表示されるため、varsIgnorePattern に関数名の追加が必要になります。

テストの追加

次にテストのための jest 導入と、実際にテストを一つ追加していきます。

yarn add -D @types/jest jest ts-jest

jest.config.js を追加します。

// jest.config.js
module.exports = {
  roots: ["<rootDir>/src"],
  testMatch: ["**/__tests__/**/*.+(ts|js)", "**/?(*.)+(spec|test).+(ts|js)"],
  transform: {
    "^.+\\.ts$": "ts-jest",
  },
};

簡単なテストを用意しておきます。

// src/hello.test.ts
import { greeter } from "./hello";

describe(greeter.name, () => {
  it("should return greeting", () => {
    const greeting = greeter("John");

    expect(greeting).toBe("Hello, John!");
  });
});

GitHub Actions

Lint と Test

main ブランチにプルリクエストがでたときに、Lint と Test が実行されるようにしておきます。

.github/workflows/test.yml
name: Test

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - main

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v2-beta
        with:
          node-version: "14"

      - id: yarn-cache-dir-path
        run: echo "::set-output name=dir::$(yarn cache dir)"

      - uses: actions/cache@v2
        id: yarn-cache
        with:
          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-

      - run: yarn install --frozen-lockfile

      - run: yarn lint

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v2-beta
        with:
          node-version: "14"

      - id: yarn-cache-dir-path
        run: echo "::set-output name=dir::$(yarn cache dir)"

      - uses: actions/cache@v2
        id: yarn-cache
        with:
          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-

      - run: yarn install --frozen-lockfile

      - run: yarn test:ci

typecheck をしていない理由

上記のエラーのスマートな解決方法がわからなかったため、ここでは typecheck を実行していません。

デプロイ

ここでは git の tag を利用してデプロイし、そのタグを npx @google/clasp version することでバージョニングをしていきます。

バージョニングをしておくと、次のように GAS のエディタ上でデプロイの管理から過去のバージョンの自動生成されたドキュメントを確認できるようになります。

.github/workflows/release.yml
name: Publish Release

on:
  push:
    tags:
      - "v*"

jobs:
  release:
    runs-on: ubuntu-latest

    env:
      CLASPRC_ACCESS_TOKEN: ${{ secrets.CLASPRC_ACCESS_TOKEN }}
      CLASPRC_CLIENT_ID: ${{ secrets.CLASPRC_CLIENT_ID }}
      CLASPRC_CLIENT_SECRET: ${{ secrets.CLASPRC_CLIENT_SECRET }}
      CLASPRC_EXPIRY_DATE: ${{ secrets.CLASPRC_EXPIRY_DATE }}
      CLASPRC_ID_TOKEN: ${{ secrets.CLASPRC_ID_TOKEN }}
      CLASPRC_REFRESH_TOKEN: ${{ secrets.CLASPRC_REFRESH_TOKEN }}
      CLASP_SCRIPT_ID: ${{ secrets.CLASP_SCRIPT_ID }}

    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v2-beta
        with:
          node-version: "14"

      - name: Cache node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: ~/.npm
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-

      - name: Create ~/.clasprc.json
        run: |
          echo $(cat <<-EOS
          {
            "token": {
              "access_token": "${CLASPRC_ACCESS_TOKEN}",
              "scope": "https://www.googleapis.com/auth/script.deployments https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/drive.file openid https://www.googleapis.com/auth/service.management https://www.googleapis.com/auth/script.projects https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/logging.read https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/script.webapp.deploy",
              "token_type": "Bearer",
              "id_token": "${CLASPRC_ID_TOKEN}",
              "expiry_date": ${CLASPRC_EXPIRY_DATE},
              "refresh_token": "${CLASPRC_REFRESH_TOKEN}"
            },
            "oauth2ClientSettings": {
              "clientId": "${CLASPRC_CLIENT_ID}",
              "clientSecret": "${CLASPRC_CLIENT_SECRET}",
              "redirectUri": "http://localhost"
            },
            "isLocalCreds": false
          }
          EOS
          ) > ~/.clasprc.json

      - name: Create ~/.clasp.json
        run: |
          echo $(cat <<-EOS
          {
            "scriptId": "${CLASP_SCRIPT_ID}"
          }
          EOS
          ) > ./.clasp.json

      - name: Get version
        id: get_version
        run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}

      - name: Upload files
        run: npx @google/clasp push --force

      - name: Add version
        run: npx @google/clasp version ${{ steps.get_version.outputs.VERSION }}

いくつか GitHub に環境変数を設定しておく必要があります。

デプロイは git tag v1.0.0 のようにタグを付けた後に、git push origin v1.0.0 としてタグを push するだけです。clasp open でブラウザ上で Apps Script のエディタを開くことができるので、デプロイされているか確認してみましょう。

secrets には JSON をそのまま値として入れておけばいい?

GitHub がログのシークレットを確実に削除するよう、シークレットの値として構造化データを使用しないでください。 たとえば、JSONやエンコードされたGit blobを含むシークレットは作成しないでください。

.clasprc.json.clasp.json を JSON のまま secrets に入れるようなことはは、公式ドキュメントで作成しないでくださいと書かれているので、それぞれ一つずつ設定し、JSON の生成を GitHub Actions で行いました。

ためしにトリガーで実行してみる

エディタ上で確認することもできますが、ためしにトリガーを使用して testGreeter を定期実行してみます。

左のメニューバーにあるトリガーから、新規のトリガーを追加します。

ここでは一分ごとに定期実行させてみました。数分待った後に、実行数を確認してみましょう。

実行できていそうです。

トリガーの実行時間は、無料枠で執筆時点で 90 min / day ですので、時間のかかる処理を定期実行する場合には注意が必要です。その他にも API の実行回数もありますので、実際に使用する場合には下記のページで確認しておきましょう。

まとめ

以前はトランスパイルが必要だった clasp も、今では TypeScript のままデプロイが可能になりました。さらに GitHub Actions を使って簡単にテストの実行、デプロイが可能です。GAS のトリガーを使えば無料枠内でも定期的にいろんなことを実行することもできます。