newmo 技術ブログ

技術で地域をカラフルに

GitHub ActionsのJobが落ちたときに何をするべきかを記述するPlaybookの仕組みを作って運用している話

newmoではGitHub Actionsを自動テスト、Lint、デプロイなどに利用しています。 また、newmoではmonorepoで開発しているため、1つのリポジトリに複数のチーム/複数のアプリケーションが存在しています。

GitHub Actionsではpathsを使うことで、特定のファイルが変更された場合のみ特定のWorkflowが実行できます。 newmoのmonorepoのworkflowでは基本的にpathsが指定されていますが、それでも普段は触らないファイルを変更して意図せずにCIが落ちることがあります。 GitHub ActionsのCIが落ちたときに、そのCIの仕組みを作った人やチーム以外だと何をすべきかわからないことがあります。

この問題の解決するを手助けするシンプルな仕組みとして、GitHub ActionsにCIが落ちたときに何をするべきかを表示するPlaybookの仕組みを導入しました。

Playbookの仕組み

Playbookといってもやっていることはとても単純です。

GitHub ActionsのWorkflowのJobが失敗したときに、そのJobが失敗した理由とその対処方法を表示するだけです。

具体的には次のようなcomposite actionを作成し、各Jobの最後にif: failure()で実行するようにしています。 Composite actionはGitHub ActionsのWorkflowから呼び出せる関数的なActionを定義する仕組みです。

次のようなComposite actionを作成します。

.github/actions/playbook/action.yaml:

---
name: "playbook"
description: "CIのJOBが落ちた時にどのように対応するべきかを書く"
inputs:
  message:
    description: "How to fix?"
    required: true
runs:
  using: "composite"
  steps:
    - name: How to Fix?
      uses: actions/github-script@v7.0.1
      env:
        INPUT_MESSAGE: ${{ inputs.message }}
      with:
        script: |
          const message = process.env.INPUT_MESSAGE;
          core.summary.addRaw(message, true);
          core.summary.write(); // Summaryへ出力
          core.setFailed(message); // Jobページ開いた時に自動的に開いた状態にするためFailedを設定し直す

そして、Workflowの各Jobの最後に次のように記述するだけです。

.github/workflows/ci.yaml:

name: CI Build

jobs:
  build:
    permissions:
      contents: "read"
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: 既存の色々な処理
        run: echo "既存の処理"
      # Jobが落ちた場合のみ、Playbookを実行する
      - name: Playbook
        if: failure()
        uses: ./.github/actions/playbook
        with:
          message: |
            # ${{ github.job }} が失敗しました

            ## 影響
            - 何が影響を受けるか

            ## 調査方法
            - Jobの落ちてるステップのエラーログを確認してください

            ## 修正方法 
            - どのように対応するか

            ## 修正後の確認方法
            - 修正後にどのように確認するか

これで、CIが落ちたときにGitHub ActionsのログやJob Summariesに対処方法が表示されるようになります。

PlaybookのJob Summaryの表示例

あとは、これを見て対処するだけです。

newmoのCIでは、各Jobに次のようなテンプレートでPlaybookを記述しています。

# ${{ github.job }} が失敗しました

## 影響
- 何が影響を受けるか
- e.g. デプロイが失敗しているので、本番環境に反映されていない

## 調査方法
- 原因となるエラーを特性する方法を記述する
- e.g. Jobの落ちてるステップのエラーログを確認してください

## 修正方法 
- どのように対応するか
- e.g. どのファイルを修正すればいいか

## 修正後の確認方法
- 修正後にどのように確認するか
- e.g. どのコマンドを実行すればいいか

CIのWorkflowを書いた人は、CI上に表示されるエラーを見るだけでわかるかもしれませんが、必ずしも他の人がわかるとは限りません。 そのため、インシデント対応のプレイブックのように、CIが落ちたときに何をすべきかを表示するPlaybookの仕組みを導入することで、CIの運用を円滑にすることができます。

実際にこの仕組みを導入してから、フロントエンドに関するCIが落ちた場合にも、普段はフロントエンドを触っていないバックエンドのエンジニアがCIが落ちた原因を修正できる事例もありました。

おわりに

newmoでは、GitHub ActionsのWorkflowが落ちたときに何をすべきかを表示するPlaybookの仕組みを導入しています。 仕組み的にはとてもシンプルで、if: failure()で何をすればいいかを書けるだけの仕組みです。 単純な仕組みですが、CIの運用を円滑にするためにはとても有効な仕組みだと感じています。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する

newmoでは、フロントエンド、バックエンド、iOSやAndroidなどのモバイルアプリをすべて同じリポジトリで管理するmonorepoを採用しています。 monorepoを採用することで、アプリケーション間で共通のコードを共有することができたり、CIの管理が楽になったり、他のチームのコードを見るのにわざわざリポジトリをcloneする必要がなくなります。 また、monorepoを採用することで、アプリケーションが利用しているパッケージ(ライブラリやツール)のバージョンを1つだけにするOne Version Ruleが実装できます。

One Version Rule

One Version Ruleは、monorepo内のパッケージのパッケージのバージョンを1つだけにするルールです。

One Version Ruleでは、monorepo内のアプリケーションが依存するパッケージは1つのバージョンだけにします。 たとえば、アプリケーションAがpackageXのバージョン 1.0.0 を使っているとき、アプリケーションBもpackageXのバージョン 1.0.0 を使うように統一します。

これによって、あるパッケージが1つのバージョンだけに集約されるので、メンテナンス性がよくなったり、パッケージのアップデートに関するセキュリティ的な問題に対処しやすくなったり、Diamond dependencyのようなパッケージ同士の依存におけるバージョンのConflictが起きにくくなります。

また、副作用として新しいパッケージを入れることに慎重となるため、同じ機能を持つ異なるパッケージが入りにくい傾向があります。 雑にパッケージの依存を増やすコストが高くなるため、newmoではDesign Docを書いて議論してからパッケージを追加することが多いです。

一方で、One Version Ruleを実践するには、当たり前ですがパッケージのバージョンを1つに統一する必要があります。そのため、依存するパッケージのバージョンを1つに統一するための方法を考える必要があります。 このパッケージのバージョン管理方法は言語により異なるため、言語ごとに適切な方法を選択する必要があります。

この記事では、フロントエンドで利用するnpm Registryのパッケージのバージョンを1つに統一するOne Version Ruleを実装する方法について紹介します。

pnpm catalogを使ったOne Version Ruleの実装

newmoでは、npmのパッケージ管理ツールとしてpnpmを利用しています。 そして、pnpmのCatalogs機能を使ってOne Version Ruleを実装しています。

pnpm catalogとは

pnpm catalogは、pnpm 9.5で追加された機能で、依存する複数のパッケージに名前をつけて管理したり、パッケージの依存をカタログ的に一箇所で管理できる仕組みです。

pnpmのworkspace(複数のパッケージ = ここではアプリケーションを統合的に扱う仕組み)を定義するpnpm-workspace.yamlに、catalogを定義できるようになっています。 pnpm-workspace.yamlcatalog:には、パッケージ名とそのバージョンを定義できます。

pnpm-workspace.yaml:

packages:
  - packages/*

# Define a catalog of version ranges.
catalog:
  react: ^18.3.1
  redux: ^5.0.1

catalog: 直下のパッケージはデフォルトのカタログとして扱われ、アプリケーションのpackage.jsonからは catalog:(名前がない場合はデフォルトカタログという意味となる)で参照できるようになります。

アプリケーションのpackage.json:

{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

さらにcatalogにはNamed Catalogsという機能もあり、複数のパッケージとバージョンをまとめたものに対して名前をつけて管理することができます。

次の例では、react17react18という名前のcatalogを定義しています。

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

同じようにアプリケーションからは catalog:react17catalog:react18 で参照できます。

{
  "name": "@example/components",
  "dependencies": {
    "react": "catalog:react18",
  }
}

この機能を使うことで、monorepoにある全てのパッケージの名前とバージョンがpnpm-workspace.yamlという一つのファイルで管理できるようになります。

One Version Ruleでは、monorepoでは1つのバージョンを扱うので基本的にデフォルトカタログ(catalog:)にほとんどのパッケージを記述することになります。

パッケージをアップデートするときに、アプリケーションのコードも必要な場合があります。 複数のアプリケーションが依存しているパッケージの場合、同時にアプリケーションを修正することが難しいケースもあります。 その場合は、例外的にNamed Catalogsを使ってバージョンを分けることで、段階的にアップデートを進めることも可能です。

ここまでは、pnpmのcatalog機能の紹介でしたが、実際にOne Version Ruleをやるにはこのルールに強制力が必要です。 newmoのmonorepoでは、pnpmのHooks機能を使ってこのルールに強制力を持たせています。

pnpmのHooksを使ったOne Version Ruleの実装

pnpmのHooks機能を使うことで、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装することができます。

pnpmのHooks機能は、package.jsonの依存を解析したタイミング(readPackage)と依存関係が全て解決したタイミング(afterAllResolved)にフックする処理を.pnpmfile.cjsファイルに記述できます。

実現したいOne Version Ruleをpnpmのレベルまで落とすと、次のようなチェックをすれば良いことがわかります。

  1. アプリケーションが依存するパッケージのバージョンは workspace:* または catalog: で指定する
    • アプリケーション側には直接バージョンを指定できなくします
  2. { "<name>": "catalog:" }とアプリケーションで指定されたパッケージの実際のバージョンが pnpm-workspace.yaml に記載されている
    • pnpm-workspace.yamlで全てのパッケージのバージョンを管理するようにします
    • 基本的にはデフォルトカタログ(catalog:)にパッケージとバージョンを定義して、1つのバージョンを使うようにします
    • 例外としてnamed catalogを使うことで、複数のバージョンが存在することは許容します
  3. monorepo内のパッケージとnode_modulesのパッケージを区別できるような名前をつける
    • これはreadPackageがmonorepo内外の両方package.jsonの解析のタイミングで呼ばれるため、区別するのに必要です
    • newmoでは @newmo-app/ で始まるパッケージをmonorepo内のパッケージとして扱っています
  4. バージョンは必ずPinされたバージョンを使うようにする
    • lockファイルでバージョンは固定はされますが、pnpm-workspace.yamlを見たときにバージョンがわかるようにPinされたバージョンを使うようにしています
  5. このルールを満たさない時は、自動的に修正コマンドをエラーに表示する
    • このルールを満たさないときに手動で修正することは可能ですが、自動的に修正コマンドを表示することで、修正の手間を減らすことができます
    • 一般的とは言えないルールなので、普通のパッケージ管理ツールを使うのと同じぐらい簡単に扱えるようにする必要があります

このルールを実装した.pnpmfile.cjsは、次のようになっています。 MONOREPO_PREFIXを変更すれば、大体そのまま利用できるようになっています。

.pnpmfile.cjs (クリックで開く)

/**
 * # One Version Rule Implementation
 * **社内Notionへのリンク**
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo internal packages
 * monorpo内部のアプリケーションやmonorepo内のworkspaceのパッケージは、このprefixで始まるようにする
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const pnpmCatalogYamlFile = path.join(rootDir, "pnpm-workspace.yaml");
const pnpmCatalogYaml = fs.readFileSync(pnpmCatalogYamlFile, "utf-8");
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
const isPackageIncludedInCatalog = (pkgName) => {
  // "pkg": version
  // or
  // pkg: version
  const pkgAndVersionPattern = new RegExp(String.raw`"?${pkgName}"?: \d+\.\d+\.\d+`);
  if (pkgAndVersionPattern.test(pnpmCatalogYaml)) {
    return true;
  }
  // npm alias pattern
  // pkg: npm:...
  // e.g "@types/react": npm:types-react@19.0.0-rc.0
  const pkgAndVersionPatternWithNpm = new RegExp(String.raw`"?${pkgName}"?: npm:[\w-]{1,32}@`);
  return pkgAndVersionPatternWithNpm.test(pnpmCatalogYaml);
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map(pathFromRoot => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
            
Please make sure that all internal packages are prefixed with ${MONOREPO_PREFIX}

詳細は **社内Notionへのリンク** を参照してください。
`);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(`Invalid argument: ${pkg.name}. This function only accepts root package.json`);
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName, depVersion, depType, errorType: "invalid-semver-version"
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName, depVersion, depType: "pnpm.overrides", errorType: "invalid-semver-version"
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      if (errorPackage.errorType === "invalid-semver-version") {
        if (errorPackage.depType === "pnpm.overrides") {
          return `npm pkg set 'pnpm.overrides.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
        }
        return `npm pkg set '${errorPackage.depType}.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
      }
    }).join("\n");
    throw new Error(`Found invalid dependency versions in root package.json

This monorepo enforces all dependencies to be pinned version in root "package.json".
Please run the following commands to fix the issue:

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`
 
詳細は **社内Notionへのリンク** を参照してください。
`);
  }

};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-catalog" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in catalog
        if (!isPackageIncludedInCatalog(depName)) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "non-catalog"
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:") && !depVersion.startsWith("catalog:")) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "invalid-version"
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({ depName, depVersion, depType, packageName, errorType }) => {
      if (errorType === "non-catalog") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:") || depVersion.startsWith("catalog:")) {
          // use yq to append `depName: {latest}}` to "catalog" field
          return `yq -i '.catalog += {"${depName}": "$(npm info ${depName} version)"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
        }
        // If not `depName: version` to pnpm-workspace.yaml
        return `yq -i '.catalog += {"${depName}": "${pinVersion(depVersion)}"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='catalog:'`;
      }
    };
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      return fixCommand(errorPackage);
    }).join("\n");

    const isAddNewCatalog = Array.from(errorPackageSet).some((errorPackage) => {
      return errorPackage.errorType === "non-catalog";
    })

    throw new Error(`Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and pnpm catalogs.
Please run the following commands to fix the issue:

yq コマンドに依存しているので、 "brew install yq" で事前にインストールしてください

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`

${isAddNewCatalog ? "新しいパッケージがpnpm-workspace.yamlのcatalogに追加されます。コマンド実行後に、pnpm-workspace.yamlに追加された依存を適切なグループに移動してください" : ""}
詳細は **社内Notionへのリンク** を参照してください。

`);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved
  }
};

この.pnpmfile.cjsは、pnpm install時に実行され、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

たとえば、次のようにアプリケーションに直接バージョンを指定した状態で pnpm install を行うとエラーが発生します

  "devDependencies": {
    "@playwright/test": "catalog:",
    "@types/node": "catalog:",
    "typescript": "catalog:",
    "jquery": "^3.7.1"
  },

実行時のエラーメッセージには、このエラーを修正するコマンドも表示されるので、コマンドを実行することでエラーを修正することができます。

pnpm: Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and force the version via pnpm catalogs
Please run the following commands to fix the issue:

yq コマンドに依存しているので、"brew install yq" で事前にインストールしてください

```
cd /path/tp/newmohq/newmo-app
yq -i '.catalog += {"jquery": "3.7.1"}' "/path/tp/newmohq/newmo-app/pnpm-workspace.yaml" # @newmo-app/application-x
npm --prefix=$(find . -name package.json | xargs grep -l '"name": "@newmo-app/application-x' | xargs dirname) pkg set 'devDependencies.jquery'='catalog:'
pnpm install
```

詳細は **社内Notionへのリンク** を参照してください。

pnpm installでOne Version Ruleのチェックが行われるので、自動的にCIでも落ち、また開発者のローカルでもすぐエラーがわかるようになっています。

これによってnewmoでは、monorepo内の全てのパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

Note: Sherifのようなmonorepoに特化したLinterなどもありますが、.pnpmfile.cjsで実装するメリットは他のツールを増やす必要がない点にあります。

参考: newmoのpnpm catalog

参考までに、newmoのpnpm-workspace.yamlに記載されているパッケージのカタログを紹介します。 ここに書かれているパッケージが、現在時点(2024-08-30)でのフロントエンドで利用しているパッケージの一覧です。

packages:
  - ... 色々な内部パッケージ ...
catalog:
  # ------------------------------
  # Browser Runtimeで利用するその他の依存
  # カテゴライズが難しいが、ブラウザのRuntimeに含まれるような依存
  # ------------------------------
  # LIFFのSDK
  # Design Docs: **Design Docへのリンク**
  "@line/liff": 2.24.0
  # アニメーションを再生するプレイヤーのライブラリ
  # https://rive.app/
  "@rive-app/react-canvas": 4.12.0
  # ------------------------------
  # React関係
  # ------------------------------
  react: 19.0.0-rc-935180c7e0-20240524
  react-dom: 19.0.0-rc-935180c7e0-20240524
  "@types/react": npm:types-react@19.0.0-rc.0
  "@types/react-dom": npm:types-react-dom@19.0.0-rc.0
  # ReactのUIコンポーネントライブラリではReact Ariaを利用している
  # https://react-spectrum.adobe.com/react-aria/index.html
  # Design Doc: **Design Docへのリンク**
  react-aria-components: 1.2.1
  # ------------------------------
  # Next.js関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  next: 15.0.0-rc.0
  "@next/third-parties": 14.2.5
  # ------------------------------
  # Panda CSS関係
  # https://pandacss.com/
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  "@pandacss/dev": 0.44.0
  # 直接postcssは利用していない、Panda CSSをNext.jsと連携させるために利用している
  postcss: 8.4.41
  # ------------------------------
  # Storybook関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  storybook: 8.2.9
  "@storybook/addon-essentials": 8.2.9
  "@storybook/addon-interactions": 8.2.9
  "@storybook/addon-links": 8.2.9
  "@storybook/addon-storysource": 8.2.9
  "@storybook/blocks": 8.2.9
  "@storybook/react": 8.2.9
  # VRTには https://www.chromatic.com/ を利用している
  chromatic: 11.7.1
  "@chromatic-com/storybook": 1.6.1
  # StorybookにはViteを利用しているため、ViteのReactプラグインを入れてる
  "@storybook/react-vite": 8.2.9
  "@vitejs/plugin-react": 4.3.1
  # ------------------------------
  # Vite関係
  # https://vitejs.dev/
  # ------------------------------
  vite: 5.4.2
  # Unit TestはViteを利用している
  vitest: 2.0.3
  # ------------------------------
  # Playwright関係
  # https://playwright.dev/
  # ------------------------------
  # Integration TestはPlaywrightを利用している
  "@playwright/test": 1.46.0
  # 静的なHTMLをテストで利用する時に、ローカルサーバとしてserveを利用している
  serve: 14.2.3
  # ------------------------------
  # GraphQL関係
  # https://graphql-code-generator.com/
  # codegenのpluginやfake serverなど
  # GraphQLのruntimeでも必要な依存はここに記載する
  # ------------------------------
  graphql: 16.8.1
  "@graphql-typed-document-node/core": 3.2.0
  # GraphQLのクラインアントはApollo Clientを利用している
  "@apollo/client": 3.10.6
  # GraphQLのdevDependenciesはここに記載する
  "@graphql-codegen/cli": 5.0.2
  "@graphql-codegen/client-preset": 4.2.5
  "@newmo/graphql-codegen-plugin-typescript-react-apollo": 1.2.2
  # Fake Serverはローカル開発時に利用している
  # Design Doc: **Design Docへのリンク**
  "@newmo/graphql-fake-server": 0.11.0
  "@newmo/graphql-codegen-fake-server-client": 0.11.0
  # ------------------------------
  # ESLint関係
  # https://eslint.org/
  # ESLintの設定は/eslint.config.jsにまとめられている
  # ------------------------------
  eslint: 8.57.0
  typescript-eslint: 7.13.1
  eslint-config-next: 15.0.0-rc.0
  eslint-plugin-playwright: 1.6.2
  eslint-plugin-prettier: 5.1.3
  eslint-plugin-react: 7.34.1
  eslint-plugin-react-hooks: 4.6.2
  eslint-plugin-storybook: 0.8.0
  "@vitest/eslint-plugin": 1.0.4
  "@pandacss/eslint-plugin": 0.1.9
  "@next/eslint-plugin-next": 14.2.4
  "@graphql-eslint/eslint-plugin": 3.20.1
  # ------------------------------
  # secretlint関係
  # https://github.com/secretlint/secretlint
  # 機密情報がコミットされたらCIで落とすために入れている
  # ------------------------------
  secretlint: 8.2.4
  "@secretlint/secretlint-rule-preset-recommend": 8.2.4
  # ------------------------------
  # TypeScript関係
  # https://www.typescriptlang.org/
  # TypeScriptのcompiler pluginやtsconfigに関係するもの
  # ------------------------------
  typescript: 5.5.2
  # ------------------------------
  # Formatter関係
  # ------------------------------
  prettier: 3.2.5
  # ------------------------------
  # スクリプト/ツール関係
  # script/やcmd/などで利用する開発用のスクリプトやツールで利用するもの
  # ------------------------------
  glob: 10.3.12 # TODO: Node.js 22ではネイティブで利用できるので不要になる
  # ------------------------------
  # その他の開発系(devDependencies)の依存
  # driver-webでcloudflare pagesへデプロイするのに利用
  # ------------------------------
  wrangler: 3.57.1
  # ------------------------------
  # その他の@types
  # グルーピングされないtypesはここに記載する
  # ------------------------------
  "@types/node": 20.12.7
  # ------------------------------
  # ここから↓↓↓は、まだカテゴライズされていない依存が列挙されています
  # yqで追加した時はここに記載されるので、適切なカテゴライズをしてください。
  # ------------------------------

すでに複数のウェブアプリケーションが稼働していますが、依存はかなり最小限にしていて、また新しいパッケージを追加する際にはDesign Docを書いて議論することが多いです。 Design Docでは、導入する目的/目的外、他の選択肢との比較、メリット/デメリット、Tier(フロントエンドの移り変わりは早すぎるのかを参考に)などを議論しています。 いわゆるArchitecture Decision Record(ADR)のようなものですが、新しいパッケージを追加する際には、このような議論を行うことでなぜそのパッケージを導入するのかを明確にしています。 カタログにDesign Docへのリンクを入れることで、後から入った人もなぜそのパッケージを使っているかをわかるようにしています。

こうした議論をするのは、一度入れたパッケージを削除するのが難しいからです。 パッケージの扱い(Tier)について話すのも、どれぐらい該当のパッケージに依存してアプリケーションを作るかを関係者で認識を揃えるためです。

まとめ

newmoのフロントエンドでは、pnpmのcatalog機能を使ってOne Version Ruleを実装しています。

One Version Ruleは、monorepo内の全てのパッケージのバージョンを1つに統一するルールです。 これによって、パッケージの一覧性がよくなり、パッケージのアップデートといったメンテナンスがしやすくなります。

一方で、基本的には1つのバージョンだけを扱うことになるので、アップデート時には自動テストが重要になります。 newmoのフロントエンドでは、PlaywrightとFake Serverを使ったIntegration TestsやChromaticを使ったVRT(Visual Regression Test)など自動テストを充実させています。 ライブラリを扱うコードに対しては、Unit Testが書きにくいことも多いため、Integration Testsなどユーザーが見るものに対するテストに比重を置いています。 (主なロジックはGoで書かれたバックエンドにあるため、バックエンド側もE2Eテストなどを充実させてカバーしています)

副作用として、新しいパッケージを追加する際には、通常のバラバラのバージョンで管理するよりは心理的な障壁が高くなります。 良い面としてはちゃんと議論してからパッケージを追加することができるということですが、逆を言えばパッケージを簡単には追加できないということでもあります。 これは、newmoではちゃんと検討してからパッケージを追加するような意思決定をしたということなので、全ての開発でこの方法が適切というわけではありません。

One Version Ruleから複数バージョンへ移行するのは簡単ですが、その逆は難しいです。 このOne Version Ruleで破綻するところまでは、この方法でやってみようということで、newmoではフロントエンドを含め、Go言語のバックエンドやSwiftのiOSなども同様の方法でパッケージ管理を行っています。

PR: newmoではフロントエンドエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

Appendix: pnpm 9.5未満でのOne Version Ruleの実装

pnpm catalogはpnpm 9.5で追加された機能ですが、pnpm 9.5未満でも同様のOne Version Ruleを実装することができます。

pnpm.overridesworkspace:* を使うことで擬似的にpnpm catalogと同様の機能を実現することができます。 pnpm catalogが出る前にこの仕組みを実装して使っていて、pnpm catalogがリリースされたときには、pnpm catalogを使うように移行しました。

こちらの方式は pnpm overridesを使うのでrenovatebotなども対応しています。

pnpm catalogはまだリリースされたばかりなので、renomatebotやdependabotなどのツールはまだ対応していません。

pnpmfile.js (クリックで開く)

 /**
 * ## One Version Rule
 *
 * - Root package.json should define all versions of child packages via `pnpm.overrides`
 *  - e.g. `"pnpm": { "overrides": { "package-name": "1.0.0" } }`
 * - Each child package should use `workspaces:*` version instead of specific version
 *   - It uses the version defined in the root package.json's "pnpm.overrides"
 *   - e.g. `"dependencies": { "package-name": "workspace:*" }`
 *
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo packages
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map((pathFromRoot) => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
              
  Please make sure that all packages are prefixed with ${MONOREPO_PREFIX}
  `);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(
      `Invalid argument: ${pkg.name}. This function only accepts root package.json`
    );
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            errorType: "invalid-semver-version",
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName,
          depVersion,
          depType: "pnpm.overrides",
          errorType: "invalid-semver-version",
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        if (errorPackage.errorType === "invalid-semver-version") {
          if (errorPackage.depType === "pnpm.overrides") {
            return `npm pkg set 'pnpm.overrides.${
              errorPackage.depName
            }'='${pinVersion(errorPackage.depVersion)}'`;
          }
          return `npm pkg set '${errorPackage.depType}.${
            errorPackage.depName
          }'='${pinVersion(errorPackage.depVersion)}'`;
        }
      })
      .join("\n");
    throw new Error(`Found invalid dependency versions in root package.json
  
  This monorepo enforces all dependencies to be pinned version in root "package.json".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
    `);
  }
};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-overrides" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in root package.json
        if (!rootPkg.pnpm.overrides[depName]) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "non-overrides",
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:")) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "invalid-version",
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({
      depName,
      depVersion,
      depType,
      packageName,
      errorType,
    }) => {
      if (errorType === "non-overrides") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:")) {
          return `npm pkg set 'pnpm.overrides.${depName}'="$(npm info ${depName} version)"`;
        }
        return `npm pkg set 'pnpm.overrides.${depName}'='${pinVersion(
          depVersion
        )}'`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='workspace:*'`;
      }
    };
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        return fixCommand(errorPackage);
      })
      .join("\n");
    throw new Error(`Found packages that violate the rule
  
  This monorepo enforces all dependencies to be defined in root "package.json" and force the version via "pnpm.overrides".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
  
  詳細は **社内Notionへのリンク** を参照してください。
  
  `);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved,
  },
};

Reviewed by ito.

iOSDC Japan 2024 にて「GraphQLとスキーマファーストで切り開くライドシェアの未来」について話しました! #iosdc

iOSDC Japan 2024 にスポンサーセッションで登壇しました

こんにちは。newmoのソフトウェアエンジニアの@kuです。

先週開催されたiOSDC Japan 2024にて、Day2の夕方に「GraphQLとスキーマファーストで切り開くライドシェアの未来」というタイトルで登壇させていただきました。(トーク情報

このブログでは、当セッションの登壇資料、補足・裏話をシェアします。

 

登壇資料

speakerdeck.com

 

本セッションでは、GraphQLのディレクティブを使ってスキーマにより多くの情報を持たせ、そこからコードを生成することで、異なるソフトウェア間で一貫性のある実装を安全に、高速に行う弊社の取り組みを紹介しました。

GraphQLのディレクティブはあまり紹介されないものの、様々な用途に利用できるので、iOS開発でもより広く使われるようになるといいなと思っています。

(セッションの動画は、後日iOSDC Japan公式YouTube Channelで公開されるはず。もう少々お待ち下さい。)

 

おわりに

iOSDCでは2021以来GraphQLのセッションはなかったので、もうみんなGraphQLに興味がないのでは?と心配だったのですが、満員御礼となって嬉しかったです。ありがとうございました。

 

また、1階のnewmoブースには3日間で約200名の方に来ていただきました。newmoのことを知っている人も、知らない人も居ました。とにかく、たくさんのエンジニアと交流できてとても楽しかったです。

 

セッションやブースで話を聞いて、newmoがどんな会社かちょっと気になる…そう思った方は、newmoのキャリアページnewmoの求人一覧をご覧ください。

 

youtrust.jp

 自分とのカジュアル面談もぜひ。

 

newmoは来月開催予定のDroidKaigi 2024にもブース出展するので、DroidKaigiに参加予定の方が居たら、来月の会場でお話しましょう!

newmo は「エンジニアの楽園 vim-jp ラジオ」を応援しています! #vimjpradio

こんにちは。newmo の TechPR 担当です。

newmo は、2024年にスタートした「エンジニアの楽園 vim-jp ラジオ」を応援しています。
newmo の vimmer も他のエディタ使いも、いつも楽しく vim-jp ラジオを聞かせていただいており、協賛できることを嬉しく思います。

協賛にあたり、以下日時のお知らせコーナーにて、newmo の情報が配信される予定です。

  • 9月9日  #10 @yusukebe さん回
  • 9月16日   #11 @uzulla さん回

vimmer の方も、そうでない方も。
vim-jp ラジオの audee ページと vim-jp X アカウント(@vimjpradio)を要チェックです!

audee.jp

 

newmo は iOSDC Japan 2024 にゴールドスポンサーとして協賛します!ブース出展 & Day2にセッションも(記事内チャレンジトークン有り) #iosdc

こんにちは。newmo の TechPR 担当です。

newmo は、2024年8月22日〜24日に開催予定の iOSDC Japan 2024 にゴールドスポンサーとして協賛します!
創業1年目の会社ですが、iOSコミュニティの発展に寄与できることを嬉しく思います。

記事内にiOSDCチャレンジトークンがあります。
※ iOSDCチャレンジトークンとは、公式が催している全員参加型企画「iOSDCチャレンジ」に使用するトークンのことです。詳しくはこちらの公式案内ブログをご覧ください。

iOSDC Japan 2024 開催概要

newmo セッション情報

Day2の昼過ぎに、newmo エンジニア @ku による以下セッションを実施予定です。

より安全に、高速にiOS開発を進めていくための弊社の取組みをご紹介します。ご興味ある方は、ぜひご聴講・ご視聴ください。

iOSDCチャレンジトークン

このセッションを聞いて、

#ライドシェアの未来

について、一緒に考えてみませんか?

newmo ブース情報

前夜祭からDay2まで、オフライン会場にてブースを出展します。

ブースお品書き

newmoのビジネス紹介ポスター

創業して半年。まだiOSアプリがリリースされていないので、newmoのビジネスについてご存じない方も多いと思います。そんな方のために、創業の背景・解決したい課題・newmoの特徴などをまとめたポスターを掲示 & newmo ブース担当者が口頭で説明します。

newmo iOS Design Blueprint ポスター

以下の情報をポスターにして掲示 & ブース担当者が口頭で説明します!

  • Multiple app architecture
  • the Composable Architecture
  • Usage of Google Fleet Engine
  • Interaction with GraphQL

ノベルティ

ロゴステッカー

まだ持っている人が少ない、レアなステッカー?!

フェイスタオル

汗をかく季節。タオルは何枚あっても嬉しいですよね。

塩分チャージタブレット

この季節は熱中症に注意!汗で流れた塩分を補給しましょう。

ブラックサンダー

会場内を動き回ったりセッションを聞いて頭を使ったら、脳と身体が糖分を欲しているはず。小休憩とともにどうぞ。

イベント直前・タイムテーブルチェック会

newmo-tech.connpass.com

開催3日前の 8/19(月)に、「iOSDC Japan 2024 のタイムテーブルをチェックする会」を開催します。「既にタイムテーブルはチェック済みだよ」という方も、そうでない方も。
登壇者の @noppe さんや @ooba さん、過去参加経験のある @d_date さんや @hcrane14 さんと一緒に、わいわいしましょう。
お昼の時間帯にオンライン配信するので、ご飯を食べながらの視聴もウェルカムです!

▼申込みはこちら
https://newmo-tech.connpass.com/event/327148/

最後に

現地の様子はX(旧Twitter)の @newmotech でもポスト予定です。ぜひこちらのアカウントもフォローしておいてください。

newmoがどんな会社かちょっと気になる…そんな方は、ぜひ事前にnewmoのキャリアページをご一読ください。 careers.newmo.me

ただ今 iOSエンジニアも募集中です。newmoの求人一覧はこちらhrmos.co

それでは、当日お会いできるのを楽しみにしています!

Go Conference 2024 にシルバースポンサーとして参加しました!

プロダクト開発に採用しているプログラミング言語Goのカンファレンス「Go Conference 2024」において、newmoはSilverスポンサーとして協賛いたしました。

当日は、CTOの @sowawa 、アーキテクトの yuki.ito、そして 筆者の@yui_tang が参加しました。 5年ぶりにオフライン開催となったGo Conferenceですが、多くの参加者が朝早くから集まり、1日を通して盛り上がりを見せていました!

創業間もないnewmoですが、幸運なことにブース出展の機会を得ることができました。 多くの方々に、バックエンドアーキテクチャや事業について興味を持っていただく貴重な機会となりました。ブースにお越しいただいた皆様、ありがとうございました!

newmoブース

yuki.itoによるライブコーディングでは、アーキテクチャの説明やデバッガーを使って高速に開発する実演を行い、多くの方の目に留まりお話しさせていただきました。 より詳細な内容は、7月にシカゴで行われるGopherConと、その後開催予定のrecapイベントにて発表予定ですので、お楽しみに!

ブースでは、ロゴステッカーの他に、オリジナルブラックサンダーとGoのデバッガーであるDelveのチートシートになるクリアファイルを配布いたしました。

newmoノベルティ
Delveチートシートになるクリアファイル
ブースにてクリアファイルを持って記念撮影する来場者(掲載許可を頂いています
若干数残っておりますので、ご興味のある方はぜひnewmo Beer Bashにお越しください🍻

Go Conference 2024にSilverスポンサーとして協賛できたことは、newmoとして技術コミュニティに初めて接する機会でもあり、心より感謝しております。 今後もnewmoは、Goをはじめとするテックコミュニティへの貢献を続けてまいります。


newmo ではエンジニアを積極的に採用中です!

興味があれば DM やカジュアル面談でもお気軽に連絡ください〜

newmo株式会社はGo Conference 2024にてスポンサーブースを出します!

こんにちは、newmo 株式会社に所属しているソフトウェアエンジニアのyui_tangです。

newmoは、Go Conference 2024にSilverスポンサーとして協賛します!

2024年1月に創業したばかりの弊社が、初めて技術カンファレンスへ協賛出来ることを大変嬉しく思います。

gocon.jp

newmoからは、CTOのsowawa・アーキテクトの伊藤とわたしの3名が参加を予定しています。

出来たばかりのnewmo Tシャツを着てブースでお待ちしておりますので、ぜひお越しください。

newmoロゴ入りTシャツ
newmo Tシャツ

ブースにて、ライブコーディングや事業・プロダクトの設計についての説明等を行います。

更に、ブースへ来てくれた方々には、便利なチートシート付き特製クリアファイルオリジナルのブラックサンダーをお配りします!

会場で、皆様とお会いできるのを楽しみにしています。


newmo ではエンジニアを積極的に採用中です!

興味があれば DM やカジュアル面談でもお気軽に連絡ください〜