プレイドインターン体験記:巨大モノレポとDeveloper Experienceの改善

プレイドインターン体験記:巨大モノレポとDeveloper Experienceの改善

こんにちは。京都工芸繊維大学 情報工学課程 3年生 (学部27卒) の、白田連大 (Reo Hakuta) GitHub @ReoHakase です。
2025年8月16日から9月15日までの1ヶ月の間、Developer Experience & Performance チームのサマーインターンに参加してきたので、その成果と労働環境、そして感想をここに共有したいと思います。

IMG_7363.jpg

👤 自己紹介

私は普段、TypeScriptでフルスタック開発をすることが多く、Turborepoを使ったモノレポ構成でのフロントエンドやバックエンド開発をよく行っています。特に、Figmaでのデザイン作成からRSCやTanstack Query等を使った最適化まで、フロントエンド領域が好きです。

例: JDLA, NHK主催 DCON2025 本戦出場 (95チーム中 5位, トピー工業賞, 日立産業制御ソリューションズ賞, Quick賞)

  • Locker.ai:LLM×スマートロッカーによる自動応答遺失物管理サービス
  • Next.js / NestJS / GraphQLを使ったフルスタックTypescript開発

ちなみに、私は茨城工業高等専門学校出身で、今在籍している京都工芸繊維大学には3年次編入学してきました。有給インターンに参加するのは株式会社プレイドで2社目になります。

📜 参加した経緯

私は GitHub Actions, Vitest, Storybook (+ a11y plugin), Playwrightなどを使いCIでテストを実行したり、TurborepoのキャッシュでCIの実行を効率化したりと、もともと開発者体験向上への興味を持っていました。しかし、個人開発規模のコードベースでは大した経験は積めないと考えていました。そこで、3月末のサポーターズの1on1イベントで株式会社プレイドの方にその旨をお話ししたところ、うまく話が進み、今回サマーインターンに参加する運びとなりました。

また、株式会社プレイドの KARTE シリーズのコードベース(karte-io-systemsリポジトリ)も巨大なモノレポ構成であり、主にTypescriptで実装されていたため、私の志向と合致していたと思います。

リポジトリ内に複数のpnpmワークスペース(systems/*)があり、それぞれが複数のパッケージを持つ構成となっていました。コミットは296,000個以上、Issue/PR番号は#133000まで達していました。(もはやカラーコード)

. (karte-io-systems)
├── .git
├── package-lock.json
├── package.json
├── README.md
└── systems
		├── academy2
		├── action-editor
		├── action-table
		├── admin
		├── apiv2
		(全36system)

✨ 成果

Developer Experience & Performance チームでは、特定の製品に関わらず、コードベースを横断的に改善に取り組みます。そのため、対象領域や変更量の大きさはタスクやPRによってまちまちでした。

  • karte-io-systems
    • feat: add @plaidev/eslint-plugin-express #133382
    • namespaceのprofile名をactionlintの設定に追加する #132929
    • karte-ai-testの実行環境をGitHub Actionsからnamespaceに移行してキャッシュ効率化する #132849
    • karte-ai の vitest config から alias を削除する #132603
    • 依存関係をpncatでpnpm catalogに全てまとめて管理するよう修正 #132587
    • refactor: karte-ai-test CIのパス変換処理を別stepに抽出 #132545
    • [old-karte] front-reactでtypescript-eslintのrecommended, stylisticルールを有効化した #132117
    • [karte-ai] 変更ファイルに対するテストのみ実行するためにVitest Test Projectsとvitest run --changedを設定した #132071
    • [old-karte] front-reactをESLint v9に移行 #131871
    • [old-karte] EventItemに適切なariaロールを付与 + キーボードでの操作に対応させた #131736
    • [old-karte] SegmentConditionBuilderのdiv+labelをfieldset+legendに置き換えた #131732
    • [old-karte] MessageBubbleのpropsのroleがa11yのルールと相性が悪いため命名をtypeに変更 #131728
    • [old-karte] front-reactのESLintと型チェックが異常終了しないように修正 + CIでのfront-reactのESLintと型チェックを有効化 #131656
    • old-karteのCIで各パッケージのリント(lint, lint:i18n)が実行されない問題を修正 #13145
  • eslint-plugin
    • @plaidev/eslint-plugin-express, hono, trpcを作成 #4
    • feat: Turborepoでモノレポ環境を作成 #2
  • sour-mcp-server
    • TailwindCSS v4準拠のsour-tailwind最新版に対応 #6
  • karte-io-terraform
    • 脆弱性の静的検査ツール Trivy をterraform/aws/evaluation gcp/evaluation-karte-io-systems向けにローカル/GitHub Actionsに設定する #1448

そこで、この記事では、特に面白みがあると思う以下の5つに絞って紹介したいと思います。

1. Vitest Test Projects + vitest relatedを用いたモノレポ環境での差分対象のテスト実行

背景

プレイドが提供するKARTEシリーズのAI機能の検証・開発を進めているKARTE AIのpnpmワークスペースでは、MastraのサーバーのテストでLLMの動作検証を行っているため、毎回CIでのテスト実行時間が長くなりがちでした。そこで、変更があったファイルに関連するテストのみ実行するようにして、実行時間が短くなるように改善を試みました。

karte-io-systems/systems/karte-ai/
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── apps/
│   ├── backend/           # Mastraサーバー
│   ├── front/             # React管理画面UI
│   ├── web/               # バックエンドAPIサーバー
│   ├── *-mcp/             # 各種MCPサーバー
└── packages/
    ├── shared/            # 共有コード
    ├── shared-server/     # サーバー共有コード
    └── trpc/              # APIスキーマ

従来の実装では、Turborepoを使って各パッケージ内で各々Vitestを実行するようになっていました。そのため、各パッケージごとに vitest.config.ts が計12個存在しており、独立して設定されていました。

今回の課題の解決策として、下記の2つが有用でした。

  • vitest related <file>: 指定されたファイルとそれに依存するファイルすべてに関するテストを実行する
  • vitest run --changed=<ref>: 与えられたref(ブランチ名やコミットID)からdiffをとって変更があったファイルすべてに対し、同様の処理を行う (git diff --nameonlyでファイル名を解決 → vitest related)

ただし、今回のようなモノレポ環境で用いる場合、Vitestが内部パッケージ(workspace:指定のもの)の依存関係を正しく追跡できる必要がありました。そこで、まずVitest Test Projectsを設定しました。

従来の各パッケージ内のvitest.config.tsファイルの記述をルートに集約して、内部パッケージ(@karte-ai/*)のファイルを追跡できるようインライン化する設定をしました。

// 移行前: apps/backend-prod/vitest.config.ts, ... (削除)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node'
  },
});

// 移行後: systems/karte-ai/vitest.config.ts (追加)
import { defineConfig } from 'vitest/config';

/**
 * ソースファイルを直接参照してビルドなしで即座に反映するためのエイリアス設定。
 * 設定しないとビルド後のdist/以下のjsファイルが参照されてしまう。
 * 絶対パスでないと動かない。
 */
const alias = {
  '@karte-ai/shared': path.resolve(__dirname, './packages/shared/src/index.ts'),
  '@karte-ai/shared-server': path.resolve(__dirname, './packages/shared-server/src/index.ts'),
  '@karte-ai/trpc': path.resolve(__dirname, './packages/trpc/src/index.ts'),
};

export default defineConfig({
  test: {
    projects: [
      {
        extends: true,
        test: {
          name: 'backend',
          include: ['apps/backend/**/*.test.{ts,js}'],
          includeSource: ['apps/backend/src/**/*.{js,ts,jsx,tsx}'],
          globals: true,
          environment: 'node',
        },
      },
      // 他の11プロジェクトも同様に設定...
    ],
    forceRerunTriggers: [
      '**/package.json/**',
      '**/vitest.config.*/**',
      '**/vite.config.*/**',
      '**/pnpm-workspace.yaml/**',
      '**/tsconfig.json/**',
    ],
    alias,
    server: {
      deps: {
        inline: [/@karte-ai\\\\//], // モノレポ内パッケージのインライン化
      },
    },
  },
});

これで、パッケージを超えたファイル単位の依存関係を追跡して、変更により必要のあるテストのみ実行できるようになりました。特に、vitest run --changed=developで、developブランチからの変更ファイルのテストのみ実行して、PRの影響範囲の動作を素早く確認できます。

なお、aliasを設定したくない場合は、Node.js Type Strippingを使った上でexports.defaultにビルド前のエントリーの.tsファイルを設定すれば大丈夫です。(最終的にはこちらの解決策を採用)

一方で、vitest run --changed=<ref>git diff --name-onlyを内部で変更のあったファイル名の解決に使っているため、十分なコミットの履歴がローカルにない場合は実行することができません。CI環境でも、developブランチ(base ref)とPRのブランチ(head ref)の分岐点となるコミットがfetchされている必要があるため、fetch-depth: 1で最新コミットだけを取得しても不十分です。

調査したところ、sparse-checkoutと--shallow-excludeを使えば最小限のコミットのみfetchできそうだったため、実験してみました。しかし、karte-io-systemsリポジトリが296,000コミット以上ある巨大モノレポだったためか、これでも50s以上checkoutにかかり実用的ではありませんでした。

jobs:
  karte-ai-test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: '***'
    defaults:
      run:
        working-directory: ./systems/karte-ai
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      # A --- B --- C --- D --- E   (develop)
      #       \\\\
      #         F --- G --- H   (pr branch)
      # develop にある:A, B, C, D, E
      # pr branch にある:A, B, C, F, G, H
      # 共通の最後の祖先 (merge-base) = C
      # PR で増えた分 = F, G, H

      # 1) PR の HEAD を shallow でチェックアウト
      - name: Checkout PR head (shallow)
        uses: actions/checkout@v4
        with:
          fetch-depth: 1
          ref: ${{ github.head_ref }}
          sparse-checkout: |
            systems/karte-ai
          sparse-checkout-cone-mode: false

      # 2) PR で増えたコミットと merge-base に必要な分を取得
      - name: Fetch commits for merge-base calculation
        env:
          BASE_REF: ${{ github.base_ref }}
          HEAD_REF: ${{ github.head_ref }}
        working-directory: .
        run: |
          git sparse-checkout set systems/karte-ai
          git -c protocol.version=2 fetch origin \\\\
            --quiet \\\\
            --no-tags \\\\
            --filter=blob:none \\\\
            --shallow-exclude="$BASE_REF" \\\\
            "$HEAD_REF" \\\\
            "$BASE_REF":"$BASE_REF"
          COMMIT_COUNT=$(git rev-list --count HEAD)
          echo "Total commits reachable from HEAD: $COMMIT_COUNT"
          git sparse-checkout set systems/karte-ai

そこで、CI では git のフルフェッチや追加フェッチそのものを避けるため、GitHub REST API を用いて変更ファイル一覧を高速に取得しました(tj-actions/changed-files@v46use_rest_api: true を利用)。取得した絶対パスをリポジトリ内の相対パスに変換したうえで vitest related に渡す処理に変更したところ、2sまで短縮できました。


jobs:
  karte-ai-test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: '****'
    defaults:
      run:
        working-directory: ./systems/karte-ai
    permissions:
      contents: read
      pull-requests: read
      id-token: write
    steps:
      # 1) PR の HEAD を shallow でチェックアウト
      - name: Checkout PR head (shallow)
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          fetch-depth: 1
          ref: ${{ github.head_ref }}
          sparse-checkout: |
            systems/karte-ai
          sparse-checkout-cone-mode: false

      # 2) PR で増えた分をGitHub APIで取得
      - name: Get changed files
        if: ${{ github.event_name == 'pull_request' }}
        id: calc_changed
        uses: tj-actions/changed-files@v46
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          use_rest_api: true
          files: |
            systems/karte-ai/**
          json: false

      # ... 省略 ...

      # ファイルパス変換処理(絶対パス → karte-ai相対パス)
      - name: Convert file paths to systems/karte-ai relative
        if: ${{ github.event_name == 'pull_request' }}
        id: convert_paths
        env:
          CHANGED_ABS: ${{ steps.calc_changed.outputs.all_changed_files }}
          REMOVED_ABS: ${{ steps.calc_changed.outputs.deleted_files }}
        run: |
          # スペース区切りの文字列を配列に分解
          IFS=' ' read -r -a CHANGED_ARR <<< "${CHANGED_ABS:-}"
          IFS=' ' read -r -a REMOVED_ARR <<< "${REMOVED_ABS:-}"

          # systems/karte-ai 配下に限定し、相対パスへ変換
          CHANGED_REL_LIST=()
          for p in "${CHANGED_ARR[@]}"; do
            [[ "$p" == systems/karte-ai/* ]] && CHANGED_REL_LIST+=("${p#systems/karte-ai/}")
          done
          REMOVED_REL_LIST=()
          for p in "${REMOVED_ARR[@]}"; do
            [[ "$p" == systems/karte-ai/* ]] && REMOVED_REL_LIST+=("${p#systems/karte-ai/}")
          done

          # 変更 + 削除 を結合
          COMBINED_REL_LIST=("${CHANGED_REL_LIST[@]}" "${REMOVED_REL_LIST[@]}")
          # デバッグ出力
          echo "Changed files (karte-ai relative):"
          printf '%s\\\\n' "${COMBINED_REL_LIST[@]}"
          # outputs に設定(スペース区切り)
          echo "files=${COMBINED_REL_LIST[*]}" >> "$GITHUB_OUTPUT"

      # vitest related 実行
      - name: test (PR; related tests)
        if: ${{ github.event_name == 'pull_request' }}
        run: pnpm exec vitest related ${{ steps.convert_paths.outputs.files }} --run

      - name: test (non-PR; all tests)
        if: ${{ github.event_name != 'pull_request' }}
        run: pnpm test:all

最終的には、PR イベントでは関連テストのみを実行し、develop への push では安全性を担保するために全テストを実行するように分岐する実装にしました。結果、以下のように必要なテストだけを実行してCIを効率化することができました。

 RUN  v3.2.4 /home/runner/work/karte-io-systems/karte-io-systems/systems/karte-ai

 ↓  backend  apps/backend/src/mastra/workflows/old-karte/workflow.test.ts (3 tests | 3 skipped)
 ✓  backend  apps/backend/src/mastra/agents/copilot.test.ts (3 tests) 30328ms
   ✓ copilotAgent > Content suggestion format > コンテンツの書き換え提案が separaterで囲まれている  17605ms
   ✓ copilotAgent > Content suggestion format > 書き換え提案以外の応答ではseparatorを含まない  4824ms
 ✓  backend  apps/backend/src/mastra/scorers/copilotFormat.test.ts (3 tests) 3727ms
   ✓ copilotFormatScorer > 提案部分がある場合は score が 1 になる  3349ms
   ✓ copilotFormatScorer > 提案部分がない場合は score が 0 になる  3504ms
   ✓ copilotFormatScorer > 提案部分が必要ない場合は score が 0.5 になる  3725ms
 ✓  shared  packages/shared/src/index.test.ts (1 test) 7ms

 Test Files  3 passed | 1 skipped (4)
      Tests  7 passed | 3 skipped (10)
   Start at  05:35:41
   Duration  37.65s (transform 509ms, setup 0ms, collect 2.72s, tests 34.06s, environment 1ms, prepare 306ms)

2. 社内向け ESLint プラグイン (v9) の npm パッケージの開発

社内向けのExpress, Hono, tRPC対応のESLintプラグインを実装して、Internalパッケージとしてpublishしました。今後のメンテナンス性を考えて、以下のような構成で開発しました。

  • 複数プラグインを今後社内で開発しやすいTurborepoを使ったモノレポ構成
  • GitHub Actions + changeset + pnpmを使ったリリースとpublish
  • Vitest + eslint-vitest-rule-tester を使った正例・負例のユニットテスト
  • Biome, lefthook, tsdownなど

実装したプラグイン・ルールの要件自体は、そこまで複雑でないものでした。

例: eslint-plugin-hono/no-project-id-from-context - Hono の c.req.json() からプロジェクト ID を取得して使用することを禁止します。代わりに、以下のような安全な実装方法を選択してください。

  • c.get('currentProject')._id: セッション情報に基づいた、認証済みユーザーの現在のプロジェクト ID
  • 環境変数: サーバーサイドで管理されるプロジェクト ID
  • データベースから取得: 認証済み情報に基づいてDBから取得

上記のようなルールのuser_id, api_key版と、プラグインのExpress, tRPC版をテスト含め実装しました。また、テストについては、eslint-vitest-rule-testerのヘルパーを使うことで、ほぼ要件そのままで簡単にテストケースを実装することができました。

import { createRuleTester } from "eslint-vitest-rule-tester";
import { describe, expect, it } from "vitest";
import type { RuleName } from "../../src/rules/names";
import { noProjectIdFromContext } from "../../src/rules/no-project-id-from-context";
import { languageOptions } from "./language-options";

describe("no-project-id-from-context", () => {
    const { valid, invalid } = createRuleTester({
        name: "no-project-id-from-context" satisfies RuleName,
        rule: noProjectIdFromContext,
        languageOptions,
    });

    describe("✅許可されるケース", () => {
        it("c.get('currentProject')._id を使用してプロジェクト ID を渡す", async () => {
            const { result } = await valid({
                code: `
        import { Hono } from 'hono';
        const app = new Hono();

        app.post('/hogehoge', async (c) => {
          const currentProject = c.get('currentProject');
          const ret = await hogehoge({
            projectId: currentProject._id,
          });
          return c.json(ret.data);
        });
        `,
            });
            expect(result).toMatchSnapshot();
        });
        // ...
    });

    describe("❌禁止されるケース", () => {
        it("body.project_id を直接渡す", async () => {
            const { result } = await invalid({
                code: `
        import { Hono } from 'hono';
        const app = new Hono();

        app.post('/hogehoge', async (c) => {
          const body = await c.req.json();
          const ret = await hogehoge({ projectId: body.project_id });
          return c.json(ret.data);
        });
        `,
                errors: [{ messageId: "fromContext" }],
            });
            expect(result).toMatchSnapshot();
        });
        // ...
    });
});

さらに、Snapshotでエラーの検出場所とメッセージのテストも気軽に行うことができました。

// Vitest Snapshot v1, <https://vitest.dev/guide/snapshot.html>

exports[`no-project-id-from-context > ❌禁止されるケース > body の別名エイリアス(const foobar = await c.req.json())経由で body.project_id を渡す 1`] = `
{
  "fixed": false,
  "messages": [
    {
      "column": 58,
      "endColumn": 68,
      "endLine": 7,
      "line": 7,
      "message": "リクエストボディからプロジェクト ID を読み取らないでください。代わりに c.get('currentProject')._id など安全なソースを使用してください。",
      "messageId": "fromContext",
      "nodeType": "Identifier",
      "ruleId": "no-project-id-from-context",
      "severity": 2,
    },
  ],
  "output": "
        import { Hono } from 'hono';
        const app = new Hono();

        app.post('/hogehoge', async (c) => {
          const foobar = await c.req.json();
          const ret = await hogehoge({ projectId: foobar.project_id });
          return c.json(ret.data);
        });
        ",
  "steps": [
    {
      "fixed": false,
      "messages": [
        {
          "column": 58,
          "endColumn": 68,
          "endLine": 7,
          "line": 7,
          "message": "リクエストボディからプロジェクト ID を読み取らないでください。代わりに c.get('currentProject')._id など安全なソースを使用してください。",
          "messageId": "fromContext",
          "nodeType": "Identifier",
          "ruleId": "rule-to-test/no-project-id-from-context",
          "severity": 2,
        },
      ],
      "output": "
        import { Hono } from 'hono';
        const app = new Hono();

        app.post('/hogehoge', async (c) => {
          const foobar = await c.req.json();
          const ret = await hogehoge({ projectId: foobar.project_id });
          return c.json(ret.data);
        });
        ",
    },
  ],
}
`;

publishしたのち、一部のワークスペースで実際に導入して使用しています。

image.png

3. pncat CLIを用いた pnpm catalog への自動移行と OSS コントリビュート

monorepo 環境で依存関係のバージョンを一元管理するために、pnpm catalog への移行を pncat CLI で自動化しました。対象は2システム(pnpm workspace)で、合計47ファイルに渡る変更を伴う移行を実施しました。

背景

従来は各 package.json が個別にバージョンを持っていたため、同一パッケージの異なるバージョンが混在しやすく、インストールされる依存が重複していました。結果として、バージョンアップ時に複数箇所の更新が必要になり、メンテナンス負荷が高くなっていました。この状況を解消するため、共通バージョンを中央集権的に管理できる pnpm catalog への移行に取り組みました。

取り組み

移行には pncat を利用しました。まず簡単な設定ファイルを用意し、catalog へ自動移行しました。

pnpm i -Dw pncat

なお、pnpm catalogには、名前なしカタログ(catalog:, catalog:default)と、名前付きカタログ(catalog:<name>)が存在します。公式ドキュメント https://pnpm.io/ja/catalogs#named-catalogs の例を見る限り、名前付きカタログは、モノレポ内に複数メジャーバージョンが混在していて統一が難しい時に使うことが意図されていてるようです。

catalog:
  react: ^16.14.0
  react-dom: ^16.14.0

catalogs:
  # Can be referenced through "catalog:react17"
  react17:
    react: ^17.0.2
    react-dom: ^17.0.2

  # Can be referenced through "catalog:react18"
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0

ここで、パッケージのカテゴリ分け(test, form, validator)のような命名は、重複指定の可能性が高まりメンテナンス性が悪くなる一方で利点がなく思えるので、すべて名前なしカタログにまとめる設定にしました。(pncatのデフォルト設定はカテゴリ分けするようになっています。 https://github.com/jinghaihan/pncat/blob/main/src/rules.ts#L3)

// pncat.config.ts
import { defineConfig } from 'pncat';

export default defineConfig({
  catalogRules: [
    { name: 'default', match: [/.+/] },
  ],
});

pnpm公式ドキュメントの例のように、複数メジャーバージョンが混在している場合は、pncatの設定でバージョンに依るカタログ名のsuffixをつけることができます。

import { defineConfig, mergeCatalogRules } from 'pncat'

export default defineConfig({
  // To extend default rules instead, use: catalogRules: mergeCatalogRules([...])
  catalogRules: [
    {
      name: 'vue',
      match: ['vue', 'vue-router', 'pinia'],
      // smaller numbers represent higher priority
      priority: 15,
      // Advanced: version-specific rules
      specifierRules: [
        { specifier: '<3.0.0', suffix: 'legacy', match: ['vue'] }
      ]
    }
  ]
})

上記のようにpncat.config.tsをワークスペースルートに置いた状態で、実行手順は次のとおりです。

# 1) (必要に応じて) pnpm catalog からバージョン直指定に展開する
pnpm pncat revert

# 2) pncatの設定に従い、バージョン直指定から pnpm catalog へ自動移行
pnpm pncat migrate

移行中に複数バージョンが検出された場合は、プロンプトで採用するバージョンを選択することができます。

❯ pnpm pncat migrate
┌  pncat v0.5.3
│
◇  /Users/reo.hakuta/workspace/karte-io-systems/systems/karte-ai/pnpm-workspace.yaml ─╮
│                                                                                     │
│  + catalog:                                                                         │
│  +   '@ai-sdk/azure': 1.3.24                                                        │
│  +   '@ai-sdk/google-vertex': 2.2.27                                                │
│  +   '@ai-sdk/openai': 1.3.23                                                       │
│  +   '@ai-sdk/react': 1.2.12                                                        │
│  +   '@ai-sdk/ui-utils': 1.2.11                                                     │
│  +   '@ant-design/icons': 6.0.0                                                     │
│  +   '@ant-design/x': 1.4.0                                                         │
│  +   '@copilotkit/react-core': 1.9.1                                                │
│  +   '@copilotkit/react-ui': 1.9.1                                                  │
│  +   '@eslint/js': 9.30.1                                                           │
│  +   vite: 7.0.3                                                                    │
│  +   vitest: 3.2.4                                                                  │
│  +   zod: 3.25.76                                                                   │
│                                                                                     │
├─────────────────────────────────────────────────────────────────────────────────────╯
│
◇  continue?
│  Yes
│
●  writing pnpm-workspace.yaml
│
●  writing package.json
│
●  migrate complete
│
└  running pnpm install

Scope: all 16 workspace projects
Lockfile is up to date, resolution step is skipped
Packages: +5 -2
+++++--
Progress: resolved 5, reused 4, downloaded 0, added 1, done
Done in 1.1s using pnpm v10.15.1

移行後は pnpm-workspace.yaml に catalog セクションが生成され、共通バージョンを一元管理できるようになりました。catalog の使用を強制する運用のために catalogMode: strict を有効化し、未使用 catalog の自動削除のために cleanupUnusedCatalogs: true を設定しました。

# 例
catalog:
  '@ai-sdk/react': 1.2.12
  '@ark-ui/react': 5.15.1
  '@fortawesome/react-fontawesome': 0.2.2
  # ... ほか多数

catalogMode: strict
cleanupUnusedCatalogs: true

package.json の依存バージョンは、catalog を参照する形式に統一しました。

{
  "dependencies": {
    "@ai-sdk/react": "catalog:",
    "@ark-ui/react": "catalog:",
    "@fortawesome/react-fontawesome": "catalog:"
  }
}

これにより、 2システムでそれぞれ 127 パッケージ、90 パッケージを catalog 管理に統一しました。並びに、バージョンの一元化と重複排除により、依存更新時の作業量を削減できました。

ただし、pnpm v10.15.1では、pnpm remove のみでは次にpnpm iが実行されるまで catalog からの削除が反映されないようです。(pnpm v10.15.2 で修正予定 https://github.com/pnpm/pnpm/pull/9930 )

なお、移行過程で見つかった改善点(catalog:ではなく冗長なcatalog:defaultが指定に使われてしまう)については、pncat 本体へのコントリビュートも行いました。

4. GitHub Actions から namespace への移行によるキャッシュ効率化の検証

karte-ai-test の実行環境を従来の GitHub Actions から namespace に移行し、キャッシュ効率を大幅に改善しました。この変更により、テスト実行時間を1分14秒から32秒へと短縮し、2.3倍高速化を実現しました。

従来のジョブは主にコンテナの初期化やpnpm installのステップ実行時間が長く、開発フローのボトルネックになっていました。具体的には、あるシステムのGitHub Actionsの環境でのpnpmの実行時間は、以下のようになっていました。

  • キャッシュhitの場合: pnpm install が17秒ほど
  • キャッシュmissの場合: pnpm install が52秒ほど + actions/cache を使ったキャッシュのアップロードに45秒ほど

つまり、依存関係が頻繁に変わる状況では、キャッシュが遅いため用いる利点に欠けていました。加えて、GitHub Actions のキャッシュはリポジトリ毎の容量制限からそもそもキャッシュの活用がうまくできておらず、これらを解消するために、実行環境とキャッシュの見直しを行いました。

上記を踏まえて、Built-in Docker Image Caching + 実行マシンのローカルにある高スループットNVMeに必ずキャッシュを配置するためダウンロード/アップロードの帯域幅に縛られず、GitHub Actionsと比べて2倍高速なことを謳っている Namespace https://namespace.so/ に移行して検証してみました。

Why Namespace Runners? - High-throughput NVMe caching directly integrated with your tooling https://namespace.so/docs/solutions/github-actions

Cache Volumes are Namespace's high-performance caching solution that persists data across GitHub Actions runs. Unlike traditional caching solutions that require time-consuming uploads and downloads, Cache Volumes provide instant access to cached data through guaranteed cache locality. https://namespace.so/docs/solutions/github-actions/caching

まず、1行の変更で namespace へ移行することができます。

jobs:
  karte-ai-test:
    runs-on: namespace-profile-ubuntu2404-amd64-16core

さらに、namespaceが用意しているActionで永続化キャッシュを導入しました。(pnpm/Turborepo)

- name: Set up pnpm cache
  uses: namespacelabs/nscloud-cache-action@v1
  with:
    cache: pnpm

- name: set up namespace turborepo cache
  uses: namespace-actions/setup-turbocache@v0

この時点で、32秒まで短縮することに成功しました。

image(1).png

Namespace は Turborepo, pnpmに対してと同様に checkout 用のaction namespacelabs/nscloud-checkout-actionも用意しています。 ですが、これは sparse-checkout 非対応であり、fetch-depth: 1 とキャッシュを併用してもkarte-io-systemsリポジトリでは約15秒を要しました。一方、従来のactions/checkout と sparse-checkout の組み合わせでは約2秒で完了したため、後者を採用し続けました。

5. Trivyを使ったTerraformの静的検査の試験的導入

まず、評価用環境aws/evaluation gcp/evaluation-karte-io-systems 向けのTerraformコードの脆弱性等を静的検査ツールを試験的に導入するために、TrivyCheckov を比較検討しました。

いい開発者体験のために必須なVSCode拡張機能なので、まずその面で比較しました。すると、現在Checkov用の拡張機能PrismaCloudは、そのサービスのAPIキーが必須かつ有料との文献を見つけました。

What Happened
The company behind Checkov and the Checkov VSCode extension, BridgeCrew, was acquired by PrismaCloud some time ago, and so it has since shifted to requiring a PrismaCloud API key. So I tried to get a key, but it seems that it’s now behind a 100ft paywall, with the asking price listed as “Request a Trial”.

Checkov: De-prismafying the SAST VSCode Extension

有志がフォークしたOSS版のCheckovのVSCodeの拡張機能も存在しましたが、うまく動作しなかったため、Trivyを選定しました。

image(2).png

その後、コンフィグ trivy.yaml とVSCode拡張機能とGItHub Actionsの設定を行いました。

image(3).png

image(4).png

💼 労働環境

株式会社プレイドは、出社/リモートワークの併用とフレックス勤務制度を敷いています。私の場合は、最初の週は GINZA SIX にあるオフィスに出社し、その後は概ね京都市の自宅からリモートで週平均40時間ほど勤務しました。フレックスなので、大学に日中に用事があっても対応しやすかったです。

🚅 交通費・宿泊費

出社の際には、今年のサマーインターンでは交通費は月25,000円まで、宿泊費は50,000円まで支給となっていました。1週間出社するのには充分でした。(次回以降変更となる可能性があります)

💻 開発環境

以下の環境が与えられたので、普段通りストレスなく開発を進めることができました。

  • 16インチ M2 Max MacBook Pro (メモリ96GB) ← すごい
  • Cursor Team Plan アカウント
  • Claude Code @ Google Vertex AI アカウント
  • Orbstack Pro アカウント

image(5).png

🏢 オフィス

東京メトロ銀座駅直結の GINZA SIX 10階に位置しており、快適でした。
また、オフィス内には大きなコーヒーマシンとウォーターサーバーや会議ブース、イベント用の芝生カーペットの空間が用意されていて、MTGも息抜きもしやすかったと覚えています。机はフリーアドレスで、よくOJTの方の横に座って雑談と相談をしながら作業していました。モニターは基本4K/5Kでした。氷・牛乳・豆乳・アーモンドミルクも使い放題で、よくカフェラテを淹れて飲みながら作業していました。

IMG_0361.jpeg

IMG_0450.jpeg

🍽️ Welcome Lunch 制度

出社した際には、インターン生も含む新規入社の人が含まれる昼食などの飲食機会を対象に、1人2000円までの会社からの費用支援がある制度があり、いろいろな社員・インターン生と交流しました。銀座のオフィス周辺には、学生では普段は手が出ないご飯屋さんが多いので、いい体験でした。。

IMG_0340.jpeg

IMG_0345.jpeg

IMG_0353.jpeg

💭 感想

以前参加した別の会社のインターンはフルリモートだったため、今回が初めての出社ありインターンでした。やはり出社した方がチームメンバーとの交流も増え、いっぱいご飯に行けたり、月に1回全社で行う”しめめし会”に参加できたりするため、有意義だったと思います。たのしかったです!1ヶ月間ありがとうございました。