CircleCI や GitHub Actions の cron を祝日だけ停止させたい

先日の ua-parse-js のハイジャックの件 を受けて、業務の中で毎日動かしている On-premise Renovate の cron を土日祝に停止させたいという話が上がった。

業務の合間に書く時間がちょっと捻出できそうになかったこと、加えて汎用的なコードということもあり、プライベートでも使えそうだったので一般化した範囲でコードを書いてしまって、業務で社内用に調整する形で決着させたので、せっかくなので共有しておく。

社内が基本的に CircleCI なので特化したものと、一般的に使えるものでバリエーションごとに2つのパターンを用意した。

祝日に停止させるアプローチ

ひとまず今回は内製の Bot の運用のため、以下のような特徴があった。

  • 土日の設定自体は cron で曜日指定ができるため祝日にフォーカスして良い
  • 厳密性を重視しない
  • ミッションクリティカルな領域の話ではない

以上を考えると、コスト対効果が十分であることが望ましいと考え、自力で国のデータや Google のカレンダーを参照する形ではなく、もっとライトな Holidays JP API を活用することにした。

理由としては Renovate のレポジトリ自体が設定ファイルを含めて JS で記述していることから、祝日チェッカーを動作させるには Node.js 環境が適切であること、そして JSON 形式であれば、 JavaScript で取り回しやすく、組織ごとの休暇データと合成できることからとなる。

汎用版祝日チェッカー

以上を踏まえて作ったのがこれ。
実行環境をできるだけ問わないようにしたいため、 CommonJS かつ JavaScript で仕上げた。

/**
 * checkHoliday.js
 * https://d.potato4d.me/entry/20211102-circleci-cron-holiday
*/

const axios = require('axios')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone')
const customHolidays = require('./customHolidays')
const { execSync } = require('child_process')

dayjs.extend(utc)
dayjs.extend(timezone)

async function run() {
  try {
    const { data } = await axios.get('https://holidays-jp.github.io/api/v1/date.json')
    const holidays = [
      ...Object.keys(data),
      customHolidays
    ]
    if (holidays.includes(dayjs().tz('Asia/Tokyo').format('YYYY-MM-DD'))) {
      console.log('Today is holiday.')
      process.exit(1)
    } else {
      console.log('Today is not holiday.')
      process.exit(0)
    }
  } catch(e) {
    console.error(e)
    process.exit(1)
  }
}

run();

customHolidays は .js でも .json でも良いので、好きな休日データを以下のように記述すると良い。
例えば三ヶ日は暦上は元旦のみ休みであるが、一般的には三ヶ日はすべて祝日である。

module.exports = [
  '2021-01-02',
  '2021-01-03',
]

基本的に平日であれば実行、休日であれば実行を中断したいため、祝日はもちろん、エラー落ちの場合も平日という確証が得られないため exit code 1 を返すように処理した。

より汎用的な作りとしているため、実用する場合は CircleCI だと when を使って on_fail でキャッチしたり、 GitHub Action の場合は if で直前の step の exit code を参照できるため、そちらでキャッチすると良い。

CircleCI 特化型祝日チェッカー

と、上記で汎用的な作りにしたものの、ただ cron を動かすためだけに CircleCI 側の知識を色々と必要とするのも微妙だと感じたため、実際に今稼働させているものはもう少し愚直なものとした。

/**
 * checkHoliday.js
 * https://d.potato4d.me/entry/20211102-circleci-cron-holiday
*/

const axios = require('axios')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone')
const customHolidays = require('./modules/customHolidays')
const { execSync } = require('child_process')
const { CI } = process.env

dayjs.extend(utc)
dayjs.extend(timezone)

async function run() {
  try {
    const { data } = await axios.get('https://holidays-jp.github.io/api/v1/date.json')
    const holidays = [
      ...Object.keys(data),
      customHolidays
    ]
    if (holidays.includes(dayjs().tz('Asia/Tokyo').format('YYYY-MM-DD'))) {
      console.log('Today is holiday.')
      if (CI) {
        execSync('circleci-agent step halt')
      } else {
        process.exit(1)
      }
    } else {
      console.log('Today is not holiday.')
      process.exit(0)
    }
  } catch(e) {
    console.error(e)
    process.exit(1)
  }
}

run();

environment variable CI がある場合、それが祝日の場合は circleci-agent step halt を実行、ローカルマシンでも CI の状況を再現できるようにしつつ、通常のローカルでの開発時は exit code を見るだけで済むようにした。

終わりに

プロダクションに乗るコードに対して OSS を使うことは頻繁にあるにも関わらず、社内ツールの設定ひとつにしか影響しない API を選ぶときは、国のオフィシャルじゃないと多少不安になってしまう。

おそらくは成果物としてビルドされていたり、一度インストールしたあとの node_modules は基本的に内容が変わることはないが、API は都度 call するため、変わらないという保証がないことに起因すると考えているものの、それはそれで変な話だなと感じたり。