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
を一通りすべて試す簡単なプログラムを作成しました。
まず、全体のソースコードを先に示します。長いので折りたたんでいますので、展開してご覧ください。
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();
}
});
});
}
このプログラムの使い方は非常に簡単です。コードをローカルに保存して、 bun run moon-smoke.ts
と実行するだけです。
注意点として、どこかのプロジェクトに destroy
タスクを定義しておいてください。上記のプログラムでは root
プロジェクトに destroy
を定義することを前提として書かれていますので、構成に応じて resetWorkspace
関数の中を書き換えてください。
async function resetWorkspace(): Promise<void> {
await $`moon run root:destroy`.quiet();
}
この destroy
タスクは、 git clone
したての状態を再現できることが望ましいです。私は次のように実装しています。
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のタスク設定やパイプラインの構成を一貫してテストすることが可能となり、フレーキーな挙動を未然に防ぐことができます。