npmパッケージの代わりに独自の仕組みを構築して定数ファイルを配布する運用に切り替えた経緯と移行プロセス

Developer Experience & Performanceチーム所属の安田(@_yuheiy)です。社内の開発環境の改善をおもな業務としています。

今回は、KARTEの複数のシステム間で共有する定数ファイルを、これまで社内向けのnpmパッケージとして配布していたところから、代わりとなる独自の仕組みを構築して管理する方式に切り替えたことと、その移行プロセスについてご紹介します。

なおこの記事は、先日行われたイベント「Webフロントエンドを軸に、幅を広げたエンジニアたちの仕事」での私の発表内容をもとにしたものです。

KARTEにおける「定数」とは

プレイドのKARTEは、いくつものシステムの組み合わせによって構成されているサービスです。それらのシステムは、マイクロサービスとして開発体制やソースコードが分割されていますが、ほとんどすべてのソースコードは一つのgitリポジトリの中に集約されています。karte-io-systemsというgitリポジトリの中に、systemsというディレクトリがあり、そこでシステムごとのソースコードが管理されています。

plaidev/karte-io-systems/
├── systems/
│   ├── academy2/
│   ├── action-editor/
│   ├── action-table/
│   ├── admin/
│   ├── apiv2/
│   ├── communication/
│   ├── craft/
│   └── datahub/
└── README.md

システムごとのディレクトリの中では、ソースコードがさらにパッケージとして分割されており、npmのワークスペースなどを使って管理されています。また、多くのシステムのバックエンドはNode.jsで実装されているため、ひと通りのソースコードをnpmのエコシステムの中で扱うことができます。

plaidev/karte-io-systems/
└── systems/
    └── communication/
        ├── packages/
        │   ├── front-react/
        │   │   └── package.json
        │   ├── front/
        │   │   └── package.json
        │   ├── lib/
        │   │   └── package.json
        │   └── web/
        │       └── package.json
        └── package.json

システムごとのソースコードはこのように分割して管理されている一方、場合によっては、別々のシステム同士を連携させるために何かしらのインターフェースが必要になることもあります。KARTEではそのための一つの手法として、システムをまたいだ共通のデータをファイル化して配布することにしています。これがKARTEにおける「定数」です。

たとえば、KARTEには「定義済みイベント」という概念があり、そのデータは大元となるJavaScriptファイルの中に実装されています。

const EVENT_NAMES = [
  {
    id: 'view',
    event_name: '閲覧',
    tag_name: '閲覧',
    is_auto_send: false,
  },
  {
    id: 'buy',
    event_name: '購入',
    tag_name: '購入',
    is_auto_send: false,
  },
  {
    id: 'identify',
    event_name: 'ユーザー情報',
    tag_name: 'ユーザー情報',
    is_auto_send: false,
  },
  {
    id: 'message_open',
    event_name: '接客サービスを表示',
    short_name: '接客',
    is_auto_send: true,
  },
  {
    id: 'message_click',
    event_name: '接客サービスをクリック',
    short_name: 'クリック',
    is_auto_send: true,
  },
  ...

このほかにもこのような定数が、JavaScriptファイルの中におよそ1万行程度含まれています。そしてこれまでは、それらを個別にJSONファイルとして出力した上で、クライアントサイド用とサーバーサイド用の2つのnpmパッケージとして社内向けにpublishしていました。それが各システムのパッケージからインストールされて使われていました。

systems/communication/packages/web/package.json:

{
  "name": "communication/web",
  "dependencies": {
    "@plaidev/nodejs-constants": "x.x.x"
  }
}

systems/communication/packages/front/package.json:

{
  "name": "communication/front",
  "dependencies": {
    "@plaidev/frontend-constants": "x.x.x"
  }
}

定数をJSONファイルとして出力している理由は、一部システムの実装にはNode.jsではなくJavaやGoなどが使われているためです。それらとの連携のしやすさを考慮してJSONを採用しています。

パッケージのバージョン管理の問題

しかし、こうしてパッケージとしてpublishすると、ユーザーが使うパッケージのバージョンをどのようにアップデートし続けるかという問題が生まれます。定数の内容は日頃からアップデートされ続けており、それに伴って新しいバージョンがリリースされる一方、ユーザーが使うバージョンはアップデートされずに放置されがちでした。もしこれが一般的なライブラリであれば、ユーザーの任意のタイミングでバージョンアップする運用で問題ないでしょう。しかしこれらの定数の場合、サービスの仕様に関わるデータであるため、変更が発生するたびできるだけ早く実環境に反映すべきです。

もっとも、バージョンアップを適用するための方策が何もなかったわけではありません。かつては、新しいバージョンをリリースした直後に、ユーザーが使うバージョンも一律アップデートするという仕組みがありました。

package.jsonに含まれる定数用パッケージのバージョンをシステムをまたいで一斉にアップデートするpull requestが作成されていた

ところがあるときから、これはNode.jsのバージョン由来の問題で動作しなくなっていました。修正することもできましたが、これまでの運用方法について調査を重ねるうち、そもそもパッケージとしてpublishすることそのものに問題があると思い至りました。

パッケージのテスト方法の問題

問題の一つは、テストのしづらさです。

一般的なライブラリを開発する場合、あらかじめその用途を想定した上で、動作の正確性を保証するためのテストケースを作成します。実装を変更した際は、そのテスト結果を拠り所として変更の妥当性を判断します。

一方で件の定数は、単なる値の集まりでしかないため、それ単体で妥当性を判断することが困難です。そのため、それが実際に適用されているシステムとの整合性に基づいて判断するのが現実的です。

しかし、パッケージとしてpublishする運用では、あらかじめ実際の適用箇所に当てはめてテストすることができません。最初にパッケージを変更してpublishした上で、ユーザーが使うバージョンをアップデートすることで、ようやく実際のシステムに適用できるようになります。その際、もし間違いがあっても、気づくのはその後になってからです。テストのタイミングとしては手遅れであり、作業としても非効率的です。

定数の変更は、それぞれのシステムの一連の開発過程の中で行われることがほとんどです。そのため、対象システムの開発と併せて動作確認をしながら進められた方が効率的ではありますが、あらかじめのpublishが必要という制約のせいで、開発の手順が複雑化している状況がありました。

また、どの定数がどこから利用されているかという影響範囲がはっきりしないことも問題でした。あるシステムの都合に合わせた変更をしたときに、知らず知らずのうちに別のシステムが壊れてしまうこともありました。これはパッケージという仕組み上の弱点のように思えます。

定数ファイルをコピーして配布するための新たな仕組み

以上のような調査と検討を経た結論として、パッケージとしてのpublishを取りやめ、代わりに、定数ファイルを単にコピーして配布するという仕組みを構築することにしました。詳しく説明します。

まず、定数はこれまでシンプルに「constants」と呼ばれていましたが、一般的な意味との混合を防ぐために「shared-constants」と名付け直しました。これまでの定数ファイルや、これに関するスクリプトなどは、同名のディレクトリに配置しています。

plaidev/karte-io-systems/
└── shared-constants/
    ├── __generated__/
    ├── scripts/
    │   └── build.ts
    ├── src/
    │   └── index.ts
    ├── package.json
    ├── README.md
    └── sync-config.ts

そして、このscripts/build.tsを実行することで、次の処理がなされます:

  1. src/index.tsをもとにして__generated__にJSONファイルが出力される
  2. sync-config.tsの設定に応じて、生成されたJSONファイルが対象のディレクトリにコピーされる

src/index.tsは次のようになっています:

import admin_role from './common/admin_role';
import chat_status from './common/chat_status';
import event_name from './common/event_name';
import notification from './common/notification';
import products from './common/products';
// ...

export default {
  'common/admin_role': admin_role,
  'common/chat_status': chat_status,
  'common/event_name': event_name,
  'common/notification': notification,
  'common/products': products,
  // ...
};

オブジェクトのキーの数だけ、それに対応するJSONファイルが出力されます。

sync-config.tsは次のようになっています:

import type { SyncConfig } from './scripts/lib/sync-config';

const config: SyncConfig = [
  // ...
  {
    name: 'communication/front',
    needs: [
      'common/admin_role',
      'common/chat_status',
      'common/event_name',
      'common/products',
      // preserve from formatting
    ],
    outputDir:
      '../systems/communication/packages/front/src/generated-shared-constants',
    outputExt: 'json',
  },
  {
    name: 'communication/front-react',
    needs: [
      'common/admin_role',
      // preserve from formatting
    ],
    outputDir:
      '../systems/communication/packages/front-react/src/generated-shared-constants',
    outputExt: 'json',
  },
  {
    name: 'communication/web',
    needs: [
      'common/admin_role',
      'common/notification',
      'common/products',
      // preserve from formatting
    ],
    outputDir:
      '../systems/communication/packages/web/src/generated-shared-constants',
    outputExt: 'json',
  },
  // ...
];

export default config;

SyncConfigの型定義はValibotを使って実装しています。

sync-config.tsには、shared-constantsを参照するパッケージの設定が並びます。outputDirには出力先のディレクトリを指定し、この数の分だけディレクトリが一斉に生成されます。

plaidev/karte-io-systems/
└── systems/
    └── communication/packages/
        ├── front/src/generated-shared-constants/
        ├── front-react/src/generated-shared-constants/
        └── web/src/generated-shared-constants/

needsには、必要な定数の種類を指定します。ここに指定されたファイルだけがコピーされます。設定の種類は全部で60個ほどあります。

plaidev/karte-io-systems/
└── systems/
    └── communication/packages/
        ├── front/src/generated-shared-constants/
        |   ├── common/
        |   |   ├── admin_role.json
        |   |   ├── chat_status.json
        |   |   ├── event_name.json
        |   |   └── products.json
        |   ├── .gitattributes
        |   └── README.md
        ├── front-react/src/generated-shared-constants/
        |   ├── common/
        |   |   └── admin_role.json
        |   ├── .gitattributes
        |   └── README.md
        └── web/src/generated-shared-constants/
            ├── common/
            |   ├── admin_role.json
            |   ├── notification.json
            |   └── products.json
            ├── .gitattributes
            └── README.md

そしてこのshared-constantsの運用においては、src/index.tsにある定数の内容を変更するたび、scripts/build.tsを実行して、生成されたファイルをgitにコミットするというルールを設けます。

Pros and Cons

パッケージをpublishする代わりにshared-constantsに切り替えることで、次のようなメリットがあると考えられます:

  • すべての依存箇所を確実に最新化できる
  • publishの手順を経由せずにテストできる
  • 定数の利用箇所を明示化できる
  • 定数の変更を利用側から追跡しやすくなる

同時に、次のようなデメリットも生じます:

  • パッケージからの移行に手間がかかる
  • ビルドされたファイルが確実にコミットされることを保証できない
  • 外部リポジトリと連携できない
  • 独自の仕組みを運用できるか懸念がある

それぞれの点について、詳しく説明します。

すべての依存箇所を確実に最新化できる

パッケージはpublishするたびに新たなバージョンが生まれることになりますが、定数についてはこの仕組みをなくすことにしました。ユーザーごとに異なるバージョンを使用できてしまうことが問題なので、単一の最新バージョンだけが存在していればよいという考え方です。

shared-constantsでは、ビルドするたびに最新バージョンが一律適用されることになるので、実質的に最新バージョンしか存在しない状態が実現しました。

ちなみに、dependenciesのバージョンとしてlatestのようなタグを指定するという方法もあります。ただしその場合、新しいバージョンがリリースされるたびに明示的にnpm updateを実行しなければアップデートが適用されません。

{
  "dependencies": {
    "@plaidev/nodejs-constants": "latest"
  }
}

publishの手順を経由せずにテストできる

前述の通り、パッケージとしてpublishすることには、テストの観点において問題がありました。

shared-constantsでは、ローカルでビルドするだけで定数の変更を適用できます。そのため、各システムの開発過程の任意のタイミングで、動作確認をしながら変更を試すことができます。

また、karte-io-systemsのリポジトリでは、対象システムのファイルに変更があったときにだけCIが実行される仕組みが一般的になっています。これによって、関係のある変更だけがチェックされるようになっています。

name: '[communication] CI'
on:
  pull_request:
    paths:
      - systems/communication/**

shared-constantsによってファイルがコピーされると、このパターンにマッチするため、pull requestを作成したタイミングで影響するひと通りのシステムのCIが実行されるようになります。

定数の利用箇所を明示化できる

パッケージとしてpublishすると、どこからどのファイルが使われているかを把握しづらいことが問題になります。使用状況を解析する仕組みを作るというやり方もありますが、あまり手頃ではありませんし、即時性に欠けます。

shared-constantsでは、どこからどの種類の定数を使用するかを設定として明示的に宣言します。これによって、定数の内容を変更する際の影響範囲が明確になります。定数の変更後にビルドをすると、すべての影響箇所に差分が生まれます。そのため、影響を意識しながら変更ができることに加えて、CIによって対象箇所のテストが実行されるようになります。これによって、不用意な変更が紛れ込む危険が軽減できると考えています。

また、ビルドをするとコンソールに次のような使用状況が表示される仕組みも実装しています。

yuhei.yasuda@plaid-yuhei-yasuda shared-constants % npm run build

> shared-constants@0.0.0 build
> tsx scripts/build.ts

✅ constants files have been updated.

✅ [admin/admin-for-develop] Synchronization has been completed.
✅ [apiv2/common] Synchronization has been completed.
(中略…)
✅ [organization/front] Synchronization has been completed.

┌────────────────────────────────────────────┬─────────────────────────────────────────────┐
│ Constants Scope                            │ Needed By                                   │
├────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ common/admin_role                          │ - super-duper-fiesta                        │
│                                            │ - solid-enigma                              │
│                                            │ - fluffy-adventure                          │
│                                            │ - potential-happiness                       │
│                                            │ - cuddly-goggles                            │
│                                            │ - silver-octo-umbrella                      │
│                                            │ - expert-octo-waddle                        │
│                                            │ - animated-adventure                        │
│                                            │ - cuddly-octo-telegram                      │
│                                            │ - ideal-bassoon                             │
│                                            │ - ideal-rotary-phone                        │
├────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ common/chat_status                         │ - cuddly-parakeet                           │
│                                            │ - shiny-happiness                           │
├────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ common/contract_status                     │ - cuddly-fishstick                          │
├────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ common/data_types                          │ - animated-octo-pancake                     │
│                                            │ - laughing-telegram                         │
│                                            │ - super-duper-guacamole                     │
│                                            │ - super-duper-waddle                        │
│                                            │ - super-rotary-phone                        │
│                                            │ - upgraded-octo-sniffle                     │
├────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ common/domain                              │ - curly-octo-happiness                      │
│                                            │ - fantastic-barnacle                        │
│                                            │ - fantastic-waffle                          │
│                                            │ - ubiquitous-engine                         │
│                                            │ - verbose-lamp                              │
├────────────────────────────────────────────┼─────────────────────────────────────────────┤
(後略…)

表を出力するにはcli-table3を使っています。

定数の変更を各システム側から追跡しやすくなる

ソースコードの変更によってシステムに何かしらの問題が発生したとき、調査のためにしばしばソースコードやgitのログを読み解く必要に駆られます。その際、問題がパッケージのバージョンアップに関係している場合、原因の特定がやや面倒になりがちです。

shared-constantsでは、対象ファイルが直接コピーされるため、通常のファイルと同じように変更履歴を辿ることができます。

file-history.png

また、使用しないファイルはコピーされないので、関係のない変更は除外されるという点も良いところです。

パッケージからの移行プロセス

パッケージをpublishする運用からshared-constantsに切り替える上での最大の問題は、パッケージを使用する既存のソースコードからの移行をどのように実現するかということでした。というのも、旧来のパッケージは新旧さまざまなシステムのおよそ1200ファイルから参照されており、移行作業や動作確認にはそれなりのコストが掛かることが予想されたためです。

そこでまず最初に、移行作業を自動化するスクリプトを作成しました。そのスクリプトでは、定数ファイルを読み込むパスの書き換えと、システムのパッケージごとの設定の生成を行います。

定数ファイルは、各システムのパッケージごとに出力するようにします。パッケージのpackage.jsonがあるディレクトリから見たsrc/generated-shared-constantsディレクトリにファイルが出力されるという前提のもと、そのための設定の生成と、パスの書き換えを行います。

パスの書き換えの要件はやや複雑です。定数ファイルには4種類のカテゴリがあり、それぞれ次のようにパスを書き換える必要があります。

定数ファイル パッケージのパス shared-constantsのパス
common/*.json @plaidev/nodejs-constants/build/*.json
@plaidev/frontend-constants/build/*.json
./path/to/generated-shared-constants/common/*.json
server/*.json @plaidev/nodejs-constants/build/*.json ./path/to/generated-shared-constants/server/*.json
front/*.json @plaidev/frontend-constants/build/*.json ./path/to/generated-shared-constants/front/*.json
locales/* @plaidev/frontend-constants/build/locales/* N/A(一対一の関係でマッピングできない)

これまで、パッケージが@plaidev/nodejs-constants@plaidev/frontend-constantsに分かれていたところを、shared-constantsでは一元化することにしました。その代わり、定数のカテゴリをそのままディレクトリ構造に反映しました。これまでのパッケージではファイルがディレクトリで区分せずにフラットに展開されていたため、新しい仕様に対応するには、ファイルごとに適切なディレクトリを判別して書き換えをする必要があります。具体的には、ファイル名がcommonのカテゴリに含まれるものかどうかを検査して、含まれればcommon、そうでなければパッケージ名に応じてserverかfrontかを判別しています。残るlocalesはパッケージでの実装方法が特殊だったので、スクリプトでの自動処理の対象外として個別に手動で書き換えることにしました。

書き換え対象となるファイルの形式はさまざまで、TypeScriptやJavaScript(CommonJS)のほか、Vue.jsのSFCやCoffeeScriptなどがあり、また、コメントアウトされたソースコード等もアップデートする必要があったので、ASTなどは用いずに簡易的な正規表現を用いて置換しました。

また、システムのパッケージごとの設定は、スクリプトで読み取ったディレクトリ情報や、パスとして指定されているファイル名に応じて生成しました。特にneedsの値を列挙するのは手動では手間がかかるので、かなり自動化の恩恵を受けられた部分です。必要となった設定の数は100程度でした。

ちなみに、パッケージごとの設定やneedsの項目の並び順は、簡単なスクリプトを作成して整頓するようにしました。

shared-constants/scripts/format-sync-config.ts:

import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import syncConfig from '../sync-config';
import { comparePaths } from './lib/comparers';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const rootDir = path.join(__dirname, '..');
const syncConfigPath = path.join(rootDir, 'sync-config.ts');

const sortedSyncConfig = syncConfig
  .map(({ name, needs, outputDir, outputExt }) => ({
    name,
    needs: needs.toSorted((a, b) => comparePaths(a, b)),
    outputDir,
    outputExt,
  }))
  .toSorted((a, b) => comparePaths(a.outputDir, b.outputDir));

const content = `import type { SyncConfig } from './scripts/lib/sync-config';

const config: SyncConfig = ${JSON.stringify(sortedSyncConfig).replaceAll(
  '],"outputDir"',
  '\n// preserve from formatting\n],"outputDir"',
)};

export default config;
`;

await fsPromises.writeFile(syncConfigPath, content);

shared-constants/package.json:

{
  "scripts": {
    "format": "tsx scripts/format-sync-config.ts && prettier . --write"
  }
}

単にアルファベット順ではなく、ディレクトリ構造などを踏まえてソートするために、comparePathという関数を使っています。これは、VSCodeの実装を参考にしたものです。

自動化対象としたのは以上の通りで、そのほかの作業についてはイレギュラーな要素が多かったので個別に手動で対応することにしました。いくつか簡単に紹介すると、次のようなものです:

  • package.jsonから依存パッケージを削除する
    • プロジェクトが依存するNode.jsのバージョンやそのほかの依存パッケージのバージョンがかなり古い場合があり、ローカルではnpm uninstallが失敗する。そのため、package-lock.jsonを書き換えられないことがある。その場合、Docker経由で実行する必要がある
  • JSONファイルを扱えるようにプロジェクトのビルド設定を調整する
    • npmパッケージからJSONファイルを読み込むのとは違って、プロジェクト自身のソースコードとしてJSONファイルを扱うにはビルドツールによっては個別の設定が必要になることがある
    • tscでビルドする場合、requireによって読み込まれたJSONファイルは出力先ディレクトリにコピーされず、実行時にエラーになるため、importに書き換える
    • sync-config.tsoutputExtを変更することでJavaScriptとして出力できるようにして、JSONファイルを使わずに対応したプロジェクトもある
  • 特殊なパスエイリアスが設定されたプロジェクトに対応する

これらのような移行作業は、すべて一斉に行うのは困難であるため、スコープを細かく区切って段階的に対応を進めました。pull requestを細かく分解し、それぞれのシステムの担当者にレビューと動作確認を依頼しました。pull requestの数としては40ほどになりました。

また、移行を段階的に進める都合上、それが完了するまでは旧来のパッケージとshared-constantsを同時に機能させる必要がありました。そのため、それぞれのファイルを同時に出力する仕組みを作成し、一時的に両者を共存させる運用にしました。

移行の完了後、旧来のパッケージは廃止しました。廃止に際してそれらのソースコードを削除していると、パッケージのソースコードを直接参照している箇所が見つかりました。つまり、shared/nodejs/packages/constantsのようなディレクトリにソースコードを配置してpublishするという運用にしていたところ、そのshared/nodejs/packages/constantsディレクトリにあるファイル自体を直接参照している箇所がありました。これについては、それらしいパスの文字列が含まれていないかをリポジトリ全体から検索し、必要に応じてshared-constantsに置き換え、不要なものは削除しました。

一部の古いシステムにおいては、npmのfile:プロトコルに似た独自のLink local dependenciesという仕組みによってsharedディレクトリのソースコードを参照している箇所もありました。これもshared-constantsに置き換えた上で、不要になったLink local dependenciesへの依存は削除しました。

sharedディレクトリにあるファイルを参照している関係で、Dockerでそのディレクトリをマウントしているシステムもありました。これはshared-constantsへの移行により不要になり、ビルドプロセスが少し簡略化されました。

ビルドされたファイルのコミットを保証する仕組み

shared-constantsでは、定数を変更するたびにファイルをビルドしてgitにコミットする運用にしています。理由は前述のとおりですが、欠点としては、コミット漏れが起こる可能性があることです。

これについては、コミット漏れが無いかをCIでチェックする仕組みを作ってケアすることにしました。具体的には次のような実装です:

  1. GitHub Actionsでshared-constantsの変更が検知されれば、shared-constantsのビルドを実行する
  2. ビルドによってgitの差分が発生すればエラーにして、pull requestにその旨をコメントする

実際のワークフローファイルは次のようになっています。

.github/workflows/shared-constants--generation-check.yml:

name: '[shared-constants] Generation check'

on:
  pull_request:
    paths:
      - .github/workflows/shared-constants--generation-check.yml
      - shared-constants/**
      - '**/generated-shared-constants/**'
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write

defaults:
  run:
    working-directory: shared-constants

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  check:
    name: Check
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: shared-constants/package.json

      - name: Install dependencies
        run: npm ci
        env:
          NODE_ENV: production

      - name: Build
        run: npm run build

      - name: Fail if there are git changes
        id: check_diff
        run: |
          if [[ -n "$(git status --porcelain)" ]]; then
            exit 1
          fi

      - name: Warn with a PR comment
        if: ${{ failure() && steps.check_diff.conclusion == 'failure' }}
        run: |
          body=$(cat << EOF
          ### 🚨 shared-constantsをビルドしてください
          
          shared-constantsのビルドを実行して、定数ファイルを同期してください。生成されるすべてのファイルをコミットしてください。
          
          \`\`\`sh
          npm run build --prefix \$(git rev-parse --show-toplevel)/shared-constants
          git add --all
          git commit -m "Update shared-constants"
          git push origin head
          \`\`\`
          EOF
          )
          
          gh pr comment ${{ github.event.pull_request.number }} --body "$body"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

これによって、ビルドされたファイルがコミットされていなければ次のように警告されるようになります。

「Some checks were not successful」というタイトルとともに「Generation check」のワークフローがエラーになっている

pull requestに「shared-constantsをビルドしてください」というコメントがついている

また場合によっては、あるシステム自体を削除するという理由で、shared-constantsを使う必要がなくなることがあります。その際には、sync-config.tsから該当する設定を削除しなければいけませんが、その作業が漏れていることがよくありました。そこで、**/generated-shared-constants/**というパターンのファイルの変更時にもワークフローを実行させることで、shared-constantsが生成するファイルが削除されたことも検知して、間違いを通知できるようにしました。

ほかには、対象のpull requestにコミットをpushした時点では問題がなかったものの、その後、ベースブランチのshared-constantsの内容が変更された結果、再度ビルドが必要になっていることに気づかずそのままマージしてしまうということもありました。これについては、pull requestをマージしたタイミングでベースブランチ上のshared-constantsのビルドを実行することで、追加の作業が必要になることを通知しています。ワークフローファイルは次のように実装しています。

.github/workflows/shared-constants--generation-check-after-merge.yml:

name: '[shared-constants] Generation check after merge'

on:
  pull_request:
    types:
      - closed
    paths:
      - .github/workflows/shared-constants--generation-check-after-merge.yml
      - shared-constants/**
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write

defaults:
  run:
    working-directory: shared-constants

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  check:
    name: Check
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.ref }}

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: shared-constants/package.json

      - name: Install dependencies
        run: npm ci
        env:
          NODE_ENV: production

      - name: Build
        run: npm run build

      - name: Fail if there are git changes
        id: check_diff
        run: |
          if [[ -n "$(git status --porcelain)" ]]; then
            exit 1
          fi

      - name: Warn with a PR comment
        if: ${{ failure() && steps.check_diff.conclusion == 'failure' }}
        run: |
          body=$(cat << EOF
          ### 🚨 ${{ github.event.pull_request.base.ref }}ブランチのshared-constantsをビルドしてください
          
          ${{ github.event.pull_request.base.ref }}ブランチで変更されたshared-constantsの内容が取り込まれずにマージされたため、定数ファイルが正しく同期されていません。

          最新の${{ github.event.pull_request.base.ref }}ブランチでshared-constantsのビルドを実行した上で、新規pull requestを作成してください。

          \`\`\`sh
          git switch --create feature/update-shared-constants-${{ github.event.pull_request.number }} origin/${{ github.event.pull_request.base.ref }}
          npm run build --prefix \$(git rev-parse --show-toplevel)/shared-constants
          git add --all
          git commit -m "Update shared-constants"
          git push origin feature/update-shared-constants-${{ github.event.pull_request.number }}
          \`\`\`
          EOF
          )
          
          gh pr comment ${{ github.event.pull_request.number }} --body "$body"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

これによって、次のように警告されます。

pull requestに「developブランチのshared-constantsをビルドしてください」というコメントがついている

そして、大規模なコードベース特有の事情として、shared-constantsが生成するファイルとフォーマッターにまつわる問題もありました。karte-io-systemsのリポジトリでは、Prettier 2を使っているシステムとPrettier 3を使っているシステムが混在しています。コミット時にフォーマットされていないソースコードが含まれていればエラーになりますが、shared-constantsが生成するファイルはPrettier 3によってフォーマットされているため、Prettier 2を使っているシステムにファイルを出力するとエラー扱いになってしまいました。そのため、Prettier 2を使っているシステムでは、個別に.prettierignoreを設定してフォーマットの対象外としました。

diffのノイズを減らす

ビルドされたファイルをコミットする運用では、gitの差分が余計に発生するため、ログが煩雑になるという問題があります。これを根本的に解決することはできませんが、次のように.gitattributesを設定することでGitHubのビュー上でのノイズを軽減することはできます。

path/to/generated-shared-constants/.gitattributes:

* linguist-generated=true

これによって、次のような表示になります:

定数ファイルの差分が表示される代わりに、全面を白いスクリーンが覆い被さって「Load diff」と表示されている

shared-constantsで生成するディレクトリにこの.gitattributesを含めることで、生成されたファイルはすべてまとめて覆い隠すことができます。

外部リポジトリに変更を同期する仕組み

基本的に、shared-constantsは一つのリポジトリ内だけで完結する仕組みです。したがって、外部のgitリポジトリとの連携がしづらいのが弱点です。KARTEにまつわるほとんどソースコードはkarte-io-systemsの中に集約されているので大きな問題ではありませんが、同じ定数を参照しなければならない外部リポジトリが存在することも事実です。

そこでこれについては、karte-io-systemsの定数を変更するpull requestがマージされるたび、外部リポジトリにも変更を同期するpull requestを自動で作成する仕組みにしました。

具体的にはまず、外部リポジトリ用の設定を集約するためにshared-constants/for-external-repositoriesディレクトリを作成します。sync-config.tsには、外部リポジトリで使う定数の種類を記述し、outputDirとしてfor-external-repositories以下のディレクトリを指定します。

shared-constants/sync-config.ts:

import type { SyncConfig } from './scripts/lib/sync-config';

const config: SyncConfig = [
  {
    name: 'blitz',
    needs: [
      'common/event_name',
      'common/field_segment_type',
      // preserve from formatting
    ],
    outputDir: './for-external-repositories/blitz',
    outputExt: 'json',
  },
  // ...
];

export default config;

次に、外部リポジトリ用の情報を記述したファイルを作成します。

shared-constants/for-external-repositories/meta.json:

{
  "blitz": {
    "owner": "plaidev",
    "repo": "blitz",
    "outputDir": "./apps/backend/src/main/resources/generated-shared-constants"
  },
  // ...
}

全体の構造としては次のようになります:

plaidev/karte-io-systems/
└── shared-constants/
    ├── for-external-repositories/
    │   ├── blitz/
    │   │   └── ...
    │   └── meta.json
    └── sync-config.ts

そしてワークフローとして、pull requestをマージしたタイミングでfor-external-repositories以下のファイルの差分を検知して、外部リポジトリにpull requestを作成する仕組みを実装します。次のようになります。

.github/workflows/shared-constants--external-repository.yml:

name: '[shared-constants] Sync with external repositories'

on:
  pull_request:
    types:
      - closed
    paths:
      - .github/workflows/shared-constants--external-repository.yml
      - shared-constants/for-external-repositories/**
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  filter_repositories:
    name: Filter repositories
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    outputs:
      repositories: ${{ steps.filter.outputs.result }}
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          sparse-checkout: |
            shared-constants/for-external-repositories/meta.json
          sparse-checkout-cone-mode: false

      - name: Filter
        uses: actions/github-script@v7
        id: filter
        with:
          github-token: ${{ secrets.SHARED_GITHUB_ACCESS_TOKEN }}
          script: |
            const { default: repositories } = await import('${{ github.workspace }}/shared-constants/for-external-repositories/meta.json', { with: { type: 'json' } });

            const { data: files } = await github.rest.pulls.listFiles({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.payload.pull_request.number,
            });

            const result = [];
            for (const [key, value] of Object.entries(repositories)) {
              const hasChanges = files.some(({ filename }) => filename.startsWith(`shared-constants/for-external-repositories/${key}/`));
              if (hasChanges) {
                result.push({ key, value });
              }
            }
            return result;

  pull_request:
    name: Create the pull request for ${{ matrix.repository.key }}
    needs: filter_repositories
    if: needs.filter_repositories.outputs.repositories != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        repository: ${{ fromJson(needs.filter_repositories.outputs.repositories) }}
    timeout-minutes: 10
    steps:
      - name: Checkout karte-io-systems
        uses: actions/checkout@v4
        with:
          path: karte-io-systems
          sparse-checkout: |
            shared-constants/for-external-repositories/${{ matrix.repository.key }}

      - name: Checkout ${{ matrix.repository.key }}
        uses: actions/checkout@v4
        with:
          repository: ${{ matrix.repository.value.owner }}/${{ matrix.repository.value.repo }}
          token: ${{ secrets.SHARED_GITHUB_ACCESS_TOKEN }}
          path: ${{ matrix.repository.key }}
          sparse-checkout: |
            ${{ matrix.repository.value.outputDir }}

      - name: Sync shared-constants
        run: |
          src_dir="./karte-io-systems/shared-constants/for-external-repositories/${{ matrix.repository.key }}"
          dest_dir="./${{ matrix.repository.key }}/${{ matrix.repository.value.outputDir }}"
          rm -rf "$dest_dir"
          cp -r "$src_dir" "$dest_dir"

      - name: Check changes
        id: check_changes
        run: |
          if [[ -n "$(git status --porcelain)" ]]; then
            echo "result=true" >> $GITHUB_OUTPUT
          fi
        working-directory: ${{ matrix.repository.key }}

      - name: Create the pull request
        id: pull_request
        if: steps.check_changes.outputs.result == 'true'
        run: |
          label="shared-constants sync"

          gh label create "$label" --description="Pull requests that update shared-constants" --color 0366d6 --force

          existing_prs=$(gh pr list --label "$label" --json number --jq '.[].number')
          for pr in $existing_prs; do
            gh pr close "$pr"
          done

          new_branch="shared-constants-sync/${{ github.event.pull_request.number }}"

          git config user.name "XXX"
          git config user.email "XXX@XXX"
          git switch --create "$new_branch"
          git add --all
          git commit -m "Update shared-constants"
          git push origin "$new_branch"

          default_branch=$(gh repo view --json defaultBranchRef --jq ".defaultBranchRef.name")
          title="Update shared-constants"
          body="[karte-io-systems](https://github.com/plaidev/karte-io-systems)の[shared-constants](https://github.com/plaidev/karte-io-systems/tree/develop/shared-constants/for-external-repositories/${{ matrix.repository.key }})に合わせて更新します。"
          url=$(gh pr create --base "$default_branch" --head "$new_branch" --title "$title" --body "$body" --label "$label")
          echo "url=$url" >> $GITHUB_OUTPUT
        working-directory: ${{ matrix.repository.key }}
        env:
          GH_TOKEN: ${{ secrets.SHARED_GITHUB_ACCESS_TOKEN }}

      - name: Notify with a PR comment
        run: |
          body=$(cat << EOF
          ### ${{ matrix.repository.key }}にPRを作成しました

          shared-constantsを同期するpull requestを作成しました。

          ${{ steps.pull_request.outputs.url }}
          EOF
          )

          gh pr comment ${{ github.event.pull_request.number }} --body="$body"
        working-directory: karte-io-systems
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

このワークフローの大まかな流れは次の通りです:

  1. pull requestをマージしたタイミングでfor-external-repositories以下のファイルの差分を検知すれば、ワークフローを開始する
  2. 変更されたファイルの名前に基づいて、どのリポジトリのファイルが変更されたのかをフィルタリングする
  3. 変更があったリポジトリごとに次の処理を進める
    1. karte-io-systemsと対象の外部リポジトリの両者をチェックアウトする
    2. 外部リポジトリの対象ディレクトリにビルドしたファイルをコピーする
    3. gitの差分をチェックする
    4. 差分があれば、外部リポジトリに変更をコミットしてpull requestを作成する
    5. 外部リポジトリにpull requestを作成したことを、変更のもととなったpull requestにコメントで通知する

これによって、次のようなpull requestが作成されます:

「karte-io-systemsのshared-constantsに合わせて更新します」というpull requestが作成される

元のpull requestには、次のような通知がコメントされます:

「blitzにPRを作成しました」というコメントがついている

pull requestの作成後、外部リポジトリの担当者がマージすることで定数の変更が適用されます。

また、外部リポジトリの操作をするために、社内向けのbot用のアカウントでpersonal access tokenを作成し、双方のリポジトリへのwrite accessを付与しています。

独自の仕組みなのでドキュメントは手厚めに

このように独自の仕組みを作成すると、使い方がわからなくて迷ったり、設計意図が伝わらずにメンテナンスが難しくなったりすることがよくあります。使い方については前述のような仕組みでのケアもしつつ、概要をまとめたドキュメントを作成したり、GitHub Issuesなどで経緯をまとめたりもすることで、後からの状況把握がしやすいように心がけています。

READMEにshared-constantsの概要をまとめている。「1. 定数の定義」「2. 定数の参照箇所の登録」など

pull requestコメントに「目的」「経緯」「関連する問題」「定数共有のための新しい仕組みと運用フロー」などの項目をまとめている

そしてこの記事を書いているのは、ドキュメントには記しきれないコンテキストの一部を記録するという目的のためでもあります。

終わりに

パッケージの仕組みでは、主従関係として提供側が主で、利用者側が従属する関係にあります。したがってその性質上、利用者側の都合を汲み取りにくいという部分に難しさがあると、かねてから感じていました。そこで、もう少し双方的な仕組みを作れないかと考案したがこの事例です。

本記事は、社内の開発環境特有のさまざまな事情に基づいた特殊な事例についてまとめたものです。そのまま流用できるようなアイデアは多くはないかもしれませんが、細かいポイントで使える要素はあるはずです。みなさまの業務のご参考にしていただければ幸いです。