Moonrepo: 「なんかCI動かないんですけどー！」を未然に防ぎたかったので、moonのタスクをスモークテストするプログラムを作りました

Moonrepoは、モノレポ（monorepo）ツールです。モノレポとは、複数のプロジェクトを1つのリポジトリで管理する手法のことです。Moonrepoは、このモノレポ環境を効率的に運用するためのツールで、タスクの依存関係やパイプラインの管理を簡単に行うことができます。しかし、その利便性の裏には複雑さも潜んでおり、適切に設定しないと意図しない動作を引き起こすことがあります。

## パイプラインがフレーキーになってしまう

moonで複雑なパイプラインを構成していると、次のようなことで困ることがあります。

- 依存関係があるはずのタスクが順序どおりにならない。
- フレーキー。`moon ci` で同じ設定のはずなのに、実行するごとにうまく行ったりいかなかったりする。
- `moon ci` で動いてほしくないタスクがなぜか動いてしまう。
- `moon ci` ではうまくいくのに、 `moon run` だとうまくいかない。(その逆もしかり)
- `moon check` ではうまくいくのに、 `moon run` だとうまくいかない。 (その逆もしかり)

これらのトラブルの原因はさまざまですが、典型的なミスには次のようなものがあります。

- `deps` の書き忘れや誤り
- `local: true` の書き忘れ
- `persistent: false` の書き忘れ
- `runInCI` の設定ミス

特にフレーキーなパイプラインになっていると、チームメンバーから次のような問い合わせが頻発します。

- 「あのー、CIがなぜか通らなくて……。ちょっと見てもらえません？」
- 「ローカルで `moon run` するとエラーになってしまうんですけど」

こういった問題は、チームの開発体験を著しく悪化させる原因となります。そのため、moonのタスク設定やパイプラインの構成は慎重に、念入りに点検することが重要です。しかし、moonにはタスクを実行するコマンドが `moon ci` 、 `moon check` 、 `moon run` の3つも用意されており、それぞれを手動でテストするのは非常に手間がかかります。特に `moon run` にいたってはプロジェクト数とタスク数の掛け算になるため、手動でテストすることは現実的ではありません。

## スモークテストするプログラム

こうした課題を解決するために、 `moon ci` 、 `moon check` 、 `moon run` を一通りすべて試す簡単なプログラムを作成しました。

まず、全体のソースコードを先に示します。長いので折りたたんでいますので、展開してご覧ください。

<details><summary>moon-smoke.ts</summary>

```typescript:moon-smoke.ts
#!/usr/bin/env bun

import * as child_process from "node:child_process";
import { $ } from "bun";

try {
	await main();
} catch (error) {
	console.error((error as Error).message);
	process.exit(1);
}

async function main(): Promise<void> {
	const { projects, tasks } = await queryWorkspace();
	await runCI();
	await runCI({ concurrency: 1 });
	await runEveryChecks(projects);
	await runEveryChecks(projects, { concurrency: 1 });
	await runEveryTasks(tasks);
	await runEveryTasks(tasks, { concurrency: 1 });
}

async function runCI({ concurrency }: { concurrency?: number } = {}): Promise<void> {
	await resetWorkspace();
	const name = `runCI${concurrency ? `:concurrency=${concurrency}` : ""}`;
	const options = concurrency ? ["--concurrency", concurrency.toString()] : [];
	await exec(name, "moon", "ci", "--color", ...options);
}

async function runEveryChecks(projects: string[], { concurrency }: { concurrency?: number } = {}): Promise<void> {
	for (const project of projects) {
		await resetWorkspace();
		const name = `check:${project}${concurrency ? `:concurrency=${concurrency}` : ""}`;
		const options = concurrency ? ["--concurrency", concurrency.toString()] : [];
		await exec(name, "moon", "check", project, "--color", ...options);
	}
}

async function runEveryTasks(tasks: string[], { concurrency }: { concurrency?: number } = {}): Promise<void> {
	for (const task of tasks) {
		await resetWorkspace();
		const name = `run:${task}${concurrency ? `:concurrency=${concurrency}` : ""}`;
		const options = concurrency ? ["--concurrency", concurrency.toString()] : [];
		await exec(name, "moon", "run", task, "--color", ...options);
	}
}

async function queryWorkspace(): Promise<Workspace> {
	const { projects } = (await $`moon query projects --json`.json()) as MoonQueryProjectsOutput;
	const workspace: Workspace = {
		projects: [],
		tasks: [],
	};
	for (const project of projects) {
		workspace.projects.push(project.id);
		for (const task of Object.values(project.tasks)) {
			workspace.tasks.push(task.target);
		}
	}
	workspace.projects.sort();
	workspace.tasks.sort();
	return workspace;
}

type Workspace = {
	projects: string[];
	tasks: string[];
};

type MoonQueryProjectsOutput = {
	projects: Project[];
};

type Project = {
	id: string;
	tasks: Record<string, Task>;
};

type Task = {
	target: string;
};

async function resetWorkspace(): Promise<void> {
	await $`moon run root:destroy`.quiet();
}

async function exec(scope: string, command: string, ...args: string[]): Promise<void> {
	console.log(`$ ${command} ${args.join(" ")}`);
	const print = (data: Buffer) => {
		const lines = data.toString("utf8").replace(/\n$/, "").split("\n");
		for (const line of lines) {
			console.log(`${scope} | ${line}`);
		}
	};
	const cmd = child_process.spawn(command, args);
	cmd.stdout.on("data", print);
	cmd.stderr.on("data", print);
	await new Promise<void>((resolve, reject) => {
		cmd.on("exit", (code) => {
			if (code) {
				reject(new Error(`Command failed with code ${code}`));
			} else {
				resolve();
			}
		});
	});
}
```

</details>

このプログラムの使い方は非常に簡単です。コードをローカルに保存して、 `bun run moon-smoke.ts` と実行するだけです。

注意点として、どこかのプロジェクトに `destroy` タスクを定義しておいてください。上記のプログラムでは `root` プロジェクトに `destroy` を定義することを前提として書かれていますので、構成に応じて `resetWorkspace` 関数の中を書き換えてください。

```typescript
async function resetWorkspace(): Promise<void> {
	await $`moon run root:destroy`.quiet();
}
```

この `destroy` タスクは、 `git clone` したての状態を再現できることが望ましいです。私は次のように実装しています。

```yaml
  destroy:
    local: true
    description: Remove everything that is git-ignored
    command: git clean
    args:
      - -d # Remove directories
      - -X # Removes only ignored files (those in .gitignore).
      - --force # Forces the removal of the files/directories.
      - --exclude=!/.idea/** # Keep .idea directory
    inputs: []
    options:
      persistent: false
      shell: false # this is needed to prevent extract glob patterns by `moon` command
```

このように設定することで、 `git clone` 直後の状態に戻すことができ、スモークテストの前提条件を整えることができます。これにより、moonのタスク設定やパイプラインの構成を一貫してテストすることが可能となり、フレーキーな挙動を未然に防ぐことができます。