🤖

Androidアプリの段階的リリースを自動化するカスタムアクションをAIに爆速で作らせた

に公開

課題

現在開発に携わっているアプリでは段階的リリースを採用しています。
またiOSアプリのリリースフローと合わせる形で、1日おきに公開率を上げていく運用をしています。
App Storeであれば自動的に段階的リリースを行う機能があるのですが、Google Playにはそのような機能がないため、手動で公開率を上げる必要があります。
リリースのたびに手動で操作するのは面倒なので、Google Play ConsoleのAPIを使って段階的リリースを自動化することにしました。

作ったもの

  • 段階的リリースの公開率を上げるためのGitHub Actionsのカスタムアクション、およびそれを定期実行するワークフロー

ワークフローは毎朝実行するようにして、公開率を、1% → 2% → 5% → 10% → 20% → 50% → 99.99999%[1] と上げていきます。

また、公開率を上げた際にSlackに通知するようにしています。


このワークフローのおかげで、公開率の更新忘れがなくなりました。また、GitHub Actionsくんは土日も休まず働いてくれるので、とても助かっています。

実装

カスタムアクションの作成はほとんどバイブコーディングで行いました。作業過程としては以下のような感じです。

  1. Android Publisher APIの使い方を知るためにサンプル実装を行う
  2. カスタムアクションの仕様を決める
  3. 仕様をもとにAIエージェント(Claude Code)にコードを書いてもらう

Android Publisher APIの使い方を知るためにサンプル実装を行う

Android Publisher APIはマイナーなAPIだと思ったので、AIに雑に作ってと言っても、うまく機能するコードが出力されるか不安に思ったので、まずはAPIの使い方を学ぶために自分の手でサンプル実装を行いました。

使用したライブラリ

ドキュメント

参考にした実装

GoogleのAPIドキュメントはこんなAPIがあってそれはこんな役割ですってことが書いてあるだけでどのように呼び出すことが正解なのかがわからなかったので、実際に動くコードの書き方の参考のために、ストアへのアップロードに使っていた、r0adkll/upload-google-playを参考にしました。

APIクライアントの作成は以下のような感じです。

sample.js
const google = require("@googleapis/androidpublisher");

const packageName = "com.example.app"; // 対象アプリのパッケージ名

const auth = new google.auth.GoogleAuth({
  scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});

// Android Publisher APIクライアントの作成
const androidpublisher = google.androidpublisher("v3");

リリースを編集するときにはトランザクションのような仕組みを使うことになります。まずedits APIを使って新規の編集を作成します

sample.js
async function getEditId() {
  const editResponse = await androidpublisher.edits.insert({
    packageName,
    auth,
  });

  return editResponse.data.id;
}

次にedits.tracks APIを使って、最新の製品版トラックを取得します

sample.js
async function getProductionTracks(editId) {
  const trackResponse = await androidpublisher.edits.tracks.list({
    packageName,
    editId,
    auth,
  });

  const productionTracks = trackResponse.data.tracks.filter((track) =>
    track.track === "production"
  );
  return productionTracks;
}

function getLatestRelease(track) {
  return track.at(0)?.releases?.at(0);
}

ここまでで、最新版のリリース情報は取得できるようになっているので、以下のようにして確認することができます。

sample.js
getEditId()
  .then((editId) => getProductionTracks(editId))
  .then((tracks) => getLatestRelease(tracks))
  .then((latestRelease) => {
    console.log('Latest Release:', latestRelease);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

これを実行するためにはGCPの設定とクレデンシャル情報が必要です。
詳しいステップは https://github.com/r0adkll/upload-google-play?tab=readme-ov-file#configure-access-via-service-account に書いてあるので、そちらを参照してください。
Workload identity authentication を使った方がよりセキュアなので推奨されるのですが、既に運用されているワークフローではサービスアカウントキーを使っていたので、今回はサービスアカウントキーを使う方法を採用することにしました。

上記手順によって取得したクレデンシャル情報が入ったjsonファイルを使って以下のようにコマンドを実行することで、最新版のリリース情報を取得できることが確認できます。

GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json node sample.js

いよいよ公開率を変更する処理を書きます。edits.tracks APIのupdaateメソッドを使って、公開率を変更します。

sample.js
async function updateUserFraction(fraction) {
  const editId = await getEditId();
  const tracks = await getProductionTracks(editId);
  const latestRelease = getLatestRelease(tracks);

  if (!latestRelease) {
    console.error("No releases found in the production track");
    return;
  }

  latestRelease.userFraction = fraction;

  // 更新を保存
  await androidpublisher.edits.tracks.update({
    packageName,
    editId,
    track: "production",
    requestBody: {
      releases: [latestRelease],
    },
    auth,
  }).then();

  await androidpublisher.edits.commit({
    packageName,
    editId,
    auth,
  }).then();

  console.log(
    `Updated user fraction to ${newUserFraction} for release ${latestRelease.versionCodes}`,
  );
}

この処理は流石に実際のプロダクトで動かさないと確認が取れないので、恐る恐る公開率を1%上げる処理を実行してみたところ、無事にGoogle Play Console上で公開率が上がっていることが確認できました。

正しいAPIの使い方がわからなかったので試行錯誤しながらここまで、実装できました。
APIの使い方がわかったところで、次にカスタムアクションの仕様を決めていきます。

カスタムアクションの仕様を決める

カスタムアクションの実装は仕様駆動開発風にAIにやってもらおうと思っているので、仕様書を書きます。

# 要件

Google Playで段階的リリースをしたアプリの公開率を更新するAction

# 仕様

引数

- serviceAccountJsonPlainText: Google Play APIを叩くためのサービスアカウントのJSONキーをプレーンテキストで指定
- packageName: アプリケーションID
- userFraction: 公開率(0.0から1.0の間)

実行環境

- Node.js 20
- TypeScript

# 実装方針

`./sample.js` を参考にして、Google PlayのPublishing
APIを使用して段階的リリースの公開率を更新する。 Publishing
APIを叩くためのクライアントライブラリとして、`@googleapis/androidpublisher`を利用する

# serviceAccountJsonPlainTextの取り扱い方

- GitHub Secretsに保存されているサービスアカウントのJSONキーをプレーンテキストで受け取る
- serviceAccountJson.jsonというファイル名で一時的に保存する
- GOOGLE_APPLICATION_CREDENTIALS環境変数を設定して、serviceAccountJson.jsonへのパスを指定する

## TODO

- [ ] 現在の公開率を取得し、アウトプットする
- [ ] 引数で指定された公開率に更新する

それと補助的にカスタムアクションのスキーマも書いておきます

action.yaml
name: Google Play Phased Release
description: This GitHub Action updates the rollout percentage of an app on Google Play

inputs:
  serviceAccountJsonPlainText:
    description: The service account JSON key as a plain text string
    required: true
  packageName:
    description: The package name of the app to update
    required: true
  userFractions:
    description: Comma-separated rollout percentages (e.g., 0.01,0.02,0.05,0.1,0.2,0.5,0.9999999)
    required: true

outputs:
  userFraction:
    description: The current user fraction
  nextUserFraction:
    description: The next user fraction that would be applied

runs:
  using: node20
  main: dist/index.js

仕様をもとにAIエージェント(Claude Code)にコードを書いてもらう

仕様書のTODOを埋めてもらう形で少しずつ実装を進めていくようにしました。

@spec.md
仕様を確認して、1つ目のTODOを実行してください。

コード自体はそこまでちゃんとみてなくて、テストコードを書かせて動作確認をしていった結果出来上がったものが以下になります。

成果物
package.json
{
  "name": "google-play-phased-release-action",
  "version": "1.0.0",
  "description": "GitHub Action to update Google Play phased release rollout percentage",
  "main": "dist/index.js",
  "scripts": {
    "build": "ncc build src/main.ts -m",
    "test": "vitest",
    "test:run": "vitest run"
  },
  "dependencies": {
    "@actions/core": "^1.10.1",
    "@googleapis/androidpublisher": "^32.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@vercel/ncc": "^0.38.1",
    "fast-check": "^4.0.0",
    "typescript": "^5.0.0",
    "vitest": "^3.0.0"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}
main.ts
import * as core from "@actions/core";
import * as google from "@googleapis/androidpublisher";
import { writeFileSync } from "fs";
import * as path from "path";

export type Release = google.androidpublisher_v3.Schema$TrackRelease;
export type Track = google.androidpublisher_v3.Schema$Track;

const androidpublisher = google.androidpublisher("v3");
const auth = new google.auth.GoogleAuth({
  scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});

/**
 * カンマ区切りの文字列をユーザーフラクション配列に変換
 * @param userFractionsStr - カンマ区切りのユーザーフラクション文字列
 * @returns ユーザーフラクション配列(昇順でソート済み)
 * @throws 不正な値が含まれている場合にエラーをスロー
 */
export function parseUserFractions(userFractionsStr: string): number[] {
  const fractions = userFractionsStr
    .split(",")
    .map((str) => str.trim())
    .filter((str) => str.length > 0)
    .map((str) => parseFloat(str));

  if (fractions.length === 0) {
    throw new Error("No user fractions provided");
  }

  for (const fraction of fractions) {
    if (isNaN(fraction) || fraction < 0 || fraction > 1) {
      throw new Error(
        `Invalid user fraction: ${fraction}. Must be between 0.0 and 1.0`,
      );
    }
  }

  return fractions.sort((a, b) => a - b);
}

/**
 * GitHub Secretsから受け取ったサービスアカウントJSONクレデンシャルを設定
 * @param serviceAccountJsonPlainText - サービスアカウントのJSONキーをプレーンテキストで指定
 */
function setupCredentials(serviceAccountJsonPlainText: string): void {
  const credentialsPath = path.join(process.cwd(), "serviceAccountJson.json");

  writeFileSync(credentialsPath, serviceAccountJsonPlainText);

  core.exportVariable("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
}

/**
 * Google Play Console API用の新しい編集セッションを作成
 * @param packageName - Androidパッケージ名 (例: 'com.example.app')
 * @returns 編集セッションID
 * @throws 編集セッションの作成に失敗した場合にエラーをスロー
 */
async function getEditId(packageName: string): Promise<string> {
  const editResponse = await androidpublisher.edits.insert({
    packageName,
    auth,
  });

  if (!editResponse.data.id) {
    throw new Error("Failed to create edit session");
  }

  return editResponse.data.id;
}

/**
 * 指定されたパッケージのプロダクショントラック情報を取得
 * @param packageName - Androidパッケージ名
 * @param editId - 編集セッションID
 * @returns プロダクショントラックの配列
 * @throws トラックが見つからない場合にエラーをスロー
 */
async function getProductionTracks(
  packageName: string,
  editId: string,
): Promise<Track[]> {
  const trackResponse = await androidpublisher.edits.tracks.list({
    packageName,
    editId,
    auth,
  });

  if (!trackResponse.data.tracks) {
    throw new Error("No tracks found");
  }

  const productionTracks = trackResponse.data.tracks.filter((track) =>
    track.track === "production"
  );
  return productionTracks;
}

/**
 * プロダクショントラックから最新のリリース情報を取得
 * @param tracks - トラックの配列
 * @returns 最新のリリース情報、または存在しない場合はnull
 */
export function getLatestRelease(tracks: Track[]): Release | null {
  const productionTrack = tracks.find((track) => track.track === "production");
  if (
    !productionTrack || !productionTrack.releases ||
    productionTrack.releases.length === 0
  ) {
    return null;
  }

  return productionTrack.releases[0];
}

/**
 * 現在の公開率から次の段階の公開率を計算
 * @param currentUserFraction - 現在の公開率
 * @param userFractions - 段階的リリースの公開率配列(昇順ソート済み)
 * @returns 次の段階の公開率、または既に最終段階の場合は現在の値
 */
export function getNextUserFraction(
  currentUserFraction: number,
  userFractions: number[],
): number {
  for (const fraction of userFractions) {
    if (fraction > currentUserFraction) {
      return fraction;
    }
  }

  return currentUserFraction;
}

/**
 * 指定されたパッケージの公開率を更新
 * @param packageName - Androidパッケージ名
 * @param editId - 編集セッションID
 * @param newUserFraction - 新しい公開率(0.0から1.0の間)
 * @returns 更新後の公開率
 * @throws Google Play APIへのアクセスに失敗した場合にエラーをスロー
 */
async function updateUserFraction(
  packageName: string,
  editId: string,
  newUserFraction: number,
): Promise<number> {
  try {
    const tracks = await getProductionTracks(packageName, editId);
    const productionTrack = tracks.find((track) =>
      track.track === "production"
    );

    if (
      !productionTrack || !productionTrack.releases ||
      productionTrack.releases.length === 0
    ) {
      throw new Error("No releases found in the production track");
    }

    const latestRelease = productionTrack.releases[0];
    const updatedRelease = {
      ...latestRelease,
      userFraction: newUserFraction,
    };

    await androidpublisher.edits.tracks.update({
      packageName,
      editId,
      track: "production",
      auth,
      requestBody: {
        track: "production",
        releases: [updatedRelease],
      },
    });

    await androidpublisher.edits.commit({
      packageName,
      editId,
      auth,
    });

    core.info(`✅ Updated user fraction to: ${newUserFraction}`);
    return newUserFraction;
  } catch (error) {
    core.error(`Error updating user fraction: ${error}`);
    throw error;
  }
}

/**
 * 指定されたパッケージの現在の公開率を取得
 * @param packageName - Androidパッケージ名
 * @returns 現在の公開率(0.0から1.0の間の数値)
 * @throws Google Play APIへのアクセスに失敗した場合、またはプロダクショントラックにリリースが見つからない場合にエラーをスロー
 */
async function getCurrentUserFraction(packageName: string): Promise<number> {
  try {
    const editId = await getEditId(packageName);
    const tracks = await getProductionTracks(packageName, editId);
    const latestRelease = getLatestRelease(tracks);

    if (!latestRelease) {
      throw new Error("No releases found in the production track");
    }

    const userFraction = latestRelease.userFraction || 0;

    core.info(`Current user fraction: ${userFraction}`);
    core.info(
      `Release version codes: ${
        latestRelease.versionCodes?.join(", ") || "N/A"
      }`,
    );
    core.info(`Release status: ${latestRelease.status || "N/A"}`);

    return userFraction;
  } catch (error) {
    core.error(`Error getting current user fraction: ${error}`);
    throw error;
  }
}

/**
 * GitHub Actionのメイン実行関数です
 * 段階的リリースの公開率を次の段階に自動更新します
 */
export async function run(): Promise<void> {
  try {
    const packageName = core.getInput("packageName", { required: true });
    const serviceAccountJsonPlainText = core.getInput(
      "serviceAccountJsonPlainText",
      { required: true },
    );
    const userFractionsStr = core.getInput("userFractions", { required: true });
    const dryRun = core.getBooleanInput("dryRun");

    setupCredentials(serviceAccountJsonPlainText);

    const userFractions = parseUserFractions(userFractionsStr);
    core.info(`Rollout schedule: ${userFractions.join(", ")}`);

    core.info(`Getting current user fraction for package: ${packageName}`);
    const currentUserFraction = await getCurrentUserFraction(packageName);

    const nextUserFraction = getNextUserFraction(
      currentUserFraction,
      userFractions,
    );

    if (nextUserFraction === currentUserFraction) {
      core.info(`✅ Already at the final stage: ${currentUserFraction}`);
      core.setOutput("userFraction", currentUserFraction.toString());
      core.setOutput("nextUserFraction", currentUserFraction.toString());
      return;
    }

    core.info(
      `Next update would be from ${currentUserFraction} to ${nextUserFraction}`,
    );

    if (dryRun) {
      core.info(
        `✅ DRY RUN: Would update user fraction from ${currentUserFraction} to ${nextUserFraction}`,
      );
    } else {
      const editId = await getEditId(packageName);
      const updatedUserFraction = await updateUserFraction(
        packageName,
        editId,
        nextUserFraction,
      );
      core.info(`✅ Updated user fraction to: ${updatedUserFraction}`);
    }

    core.setOutput("userFraction", currentUserFraction.toString());
    core.setOutput("nextUserFraction", nextUserFraction.toString());
  } catch (error) {
    core.setFailed(
      error instanceof Error ? error.message : "Unknown error occurred",
    );
  }
}

if (require.main === module) {
  run();
}
main.test.ts
import { describe, expect, it } from "vitest";
import fc from "fast-check";
import {
  getLatestRelease,
  getNextUserFraction,
  parseUserFractions,
  type Release,
  type Track,
} from "./main";

describe("parseUserFractions", () => {
  it("カンマ区切りのユーザーフラクション文字列を正しく解析できる", () => {
    const input = "0.01,0.02,0.05,0.1,0.2,0.5,0.9999999";
    const expected = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.9999999];

    const result = parseUserFractions(input);

    expect(result).toEqual(expected);
  });

  it("ユーザーフラクションを昇順でソートする", () => {
    const input = "0.5,0.01,0.1,0.02";
    const expected = [0.01, 0.02, 0.1, 0.5];

    const result = parseUserFractions(input);

    expect(result).toEqual(expected);
  });

  it("値の前後の空白を適切に処理する", () => {
    const input = " 0.01 , 0.02 , 0.05 ";
    const expected = [0.01, 0.02, 0.05];

    const result = parseUserFractions(input);

    expect(result).toEqual(expected);
  });

  it("空文字列をフィルタリングする", () => {
    const input = "0.01,,0.02,,";
    const expected = [0.01, 0.02];

    const result = parseUserFractions(input);

    expect(result).toEqual(expected);
  });

  it("空の入力に対してエラーを投げる", () => {
    expect(() => parseUserFractions("")).toThrow("No user fractions provided");
    expect(() => parseUserFractions("   ")).toThrow(
      "No user fractions provided",
    );
    expect(() => parseUserFractions(",,")).toThrow(
      "No user fractions provided",
    );
  });

  it.each([
    {
      input: "0.01,abc,0.02",
      expectedError: "Invalid user fraction: NaN",
      description: "文字列が含まれる場合",
    },
    {
      input: "0.01,1.5,0.02",
      expectedError: "Invalid user fraction: 1.5",
      description: "1より大きい値が含まれる場合",
    },
    {
      input: "0.01,-0.1,0.02",
      expectedError: "Invalid user fraction: -0.1",
      description: "負の値が含まれる場合",
    },
  ])(
    "不正なユーザーフラクション - $description",
    ({ input, expectedError }) => {
      expect(() => parseUserFractions(input)).toThrow(expectedError);
    },
  );

  it("最小値0と最大値1を適切に処理する", () => {
    const input = "0,0.5,1";
    const expected = [0, 0.5, 1];

    const result = parseUserFractions(input);

    expect(result).toEqual(expected);
  });

  it.each([
    {
      input: "0.01,1.01,0.02",
      expectedError: "Invalid user fraction: 1.01. Must be between 0.0 and 1.0",
      description: "1.01のような範囲外の値",
    },
    {
      input: "-0.01,0.5,0.02",
      expectedError:
        "Invalid user fraction: -0.01. Must be between 0.0 and 1.0",
      description: "-0.01のような負の値",
    },
  ])("0-1の範囲外エラー - $description", ({ input, expectedError }) => {
    expect(() => parseUserFractions(input)).toThrow(expectedError);
  });
});

describe("getLatestRelease", () => {
  it("プロダクショントラックから最初のリリースを返す", () => {
    const mockRelease: Release = {
      userFraction: 0.05,
      versionCodes: ["123"],
      status: "inProgress",
    };

    const tracks: Track[] = [
      {
        track: "production",
        releases: [mockRelease],
      },
    ];

    const result = getLatestRelease(tracks);

    expect(result).toEqual(mockRelease);
  });

  it("プロダクショントラックが存在しない場合はnullを返す", () => {
    const tracks: Track[] = [
      {
        track: "beta",
        releases: [{ userFraction: 0.1 }],
      },
    ];

    const result = getLatestRelease(tracks);

    expect(result).toBeNull();
  });

  it("プロダクショントラックにリリースがない場合はnullを返す", () => {
    const tracks: Track[] = [
      {
        track: "production",
        releases: [],
      },
    ];

    const result = getLatestRelease(tracks);

    expect(result).toBeNull();
  });

  it("プロダクショントラックのreleasesがundefinedの場合はnullを返す", () => {
    const tracks: Track[] = [
      {
        track: "production",
        // releases is undefined
      },
    ];

    const result = getLatestRelease(tracks);

    expect(result).toBeNull();
  });

  it("空のトラック配列に対してnullを返す", () => {
    const tracks: Track[] = [];

    const result = getLatestRelease(tracks);

    expect(result).toBeNull();
  });

  it("複数のリリースが存在する場合は最初のリリースを返す", () => {
    const firstRelease: Release = { userFraction: 0.05, versionCodes: ["123"] };
    const secondRelease: Release = { userFraction: 0.1, versionCodes: ["124"] };

    const tracks: Track[] = [
      {
        track: "production",
        releases: [firstRelease, secondRelease],
      },
    ];

    const result = getLatestRelease(tracks);

    expect(result).toEqual(firstRelease);
  });
});

describe("getNextUserFraction", () => {
  const defaultSchedule = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.9999999];

  it.each([
    { current: 0.01, expected: 0.02, description: "1%から2%へ進行" },
    { current: 0.02, expected: 0.05, description: "2%から5%へ進行" },
    { current: 0.05, expected: 0.1, description: "5%から10%へ進行" },
    { current: 0.1, expected: 0.2, description: "10%から20%へ進行" },
    { current: 0.2, expected: 0.5, description: "20%から50%へ進行" },
    {
      current: 0.5,
      expected: 0.9999999,
      description: "50%から99.99999%へ進行",
    },
  ])("$description", ({ current, expected }) => {
    expect(getNextUserFraction(current, defaultSchedule)).toBe(expected);
  });

  it("既に最終段階の場合は現在の値を返す", () => {
    const result = getNextUserFraction(0.9999999, defaultSchedule);

    expect(result).toBe(0.9999999);
  });

  it("現在の値がスケジュールのどの値よりも高い場合は現在の値を返す", () => {
    const result = getNextUserFraction(1.0, defaultSchedule);

    expect(result).toBe(1.0);
  });

  it("現在の値がスケジュールに正確にない場合は次のステージを見つける", () => {
    // 現在の値が0.02と0.05の間にある場合
    const result = getNextUserFraction(0.03, defaultSchedule);

    expect(result).toBe(0.05);
  });

  it("最初のステージより小さい値を適切に処理する", () => {
    const result = getNextUserFraction(0.005, defaultSchedule);

    expect(result).toBe(0.01);
  });

  it.each([
    {
      current: 0.05,
      schedule: [0.1, 0.25, 0.5, 1.0],
      expected: 0.1,
      description: "5%から10%へ(カスタムスケジュール)",
    },
    {
      current: 0.1,
      schedule: [0.1, 0.25, 0.5, 1.0],
      expected: 0.25,
      description: "10%から25%へ(カスタムスケジュール)",
    },
    {
      current: 0.25,
      schedule: [0.1, 0.25, 0.5, 1.0],
      expected: 0.5,
      description: "25%から50%へ(カスタムスケジュール)",
    },
    {
      current: 0.5,
      schedule: [0.1, 0.25, 0.5, 1.0],
      expected: 1.0,
      description: "50%から100%へ(カスタムスケジュール)",
    },
    {
      current: 1.0,
      schedule: [0.1, 0.25, 0.5, 1.0],
      expected: 1.0,
      description: "100%で変更なし(カスタムスケジュール)",
    },
  ])("$description", ({ current, schedule, expected }) => {
    expect(getNextUserFraction(current, schedule)).toBe(expected);
  });

  it("空のスケジュールを適切に処理する", () => {
    const result = getNextUserFraction(0.1, []);

    expect(result).toBe(0.1);
  });

  it("単一要素のスケジュールを適切に処理する", () => {
    const singleSchedule = [0.5];

    expect(getNextUserFraction(0.1, singleSchedule)).toBe(0.5);
    expect(getNextUserFraction(0.5, singleSchedule)).toBe(0.5);
    expect(getNextUserFraction(0.8, singleSchedule)).toBe(0.8);
  });

  it("最小値0と最大値1を含むスケジュールを正しく処理する", () => {
    const schedule = [0.0, 0.5, 1.0];

    expect(getNextUserFraction(0.0, schedule)).toBe(0.5);
    expect(getNextUserFraction(0.5, schedule)).toBe(1.0);
    expect(getNextUserFraction(1.0, schedule)).toBe(1.0);
  });
});

describe("Property-based tests", () => {
  describe("parseUserFractions プロパティ", () => {
    it("常に昇順でソートされた配列を返す", () => {
      fc.assert(
        fc.property(
          fc.array(fc.float({ min: 0, max: 1, noNaN: true }), {
            minLength: 1,
            maxLength: 10,
          }),
          (fractions) => {
            const input = fractions.join(",");
            const result = parseUserFractions(input);

            // プロパティ: 結果は常に昇順でソートされている
            for (let i = 1; i < result.length; i++) {
              expect(result[i]).toBeGreaterThanOrEqual(result[i - 1]);
            }
          },
        ),
      );
    });

    it("冪等性: ソート済み配列を再度ソートしても同じ結果", () => {
      fc.assert(
        fc.property(
          fc.array(fc.float({ min: 0, max: 1, noNaN: true }), {
            minLength: 1,
            maxLength: 10,
          }),
          (fractions) => {
            const sorted = fractions.sort((a, b) => a - b);
            const input = sorted.join(",");
            const result1 = parseUserFractions(input);
            const result2 = parseUserFractions(result1.join(","));

            // プロパティ: 冪等性
            expect(result2).toEqual(result1);
          },
        ),
      );
    });
  });

  describe("getNextUserFraction プロパティ", () => {
    it("結果は常に現在値以上", () => {
      fc.assert(
        fc.property(
          fc.float({ min: 0, max: 1, noNaN: true }),
          fc.array(fc.float({ min: 0, max: 1, noNaN: true }), {
            minLength: 0,
            maxLength: 10,
          }),
          (current, schedule) => {
            const sortedSchedule = schedule.sort((a, b) => a - b);
            const result = getNextUserFraction(current, sortedSchedule);

            // プロパティ: 単調性(結果 >= 現在値)
            expect(result).toBeGreaterThanOrEqual(current);
          },
        ),
      );
    });

    it("結果はスケジュール内の値または現在値", () => {
      fc.assert(
        fc.property(
          fc.float({ min: 0, max: 1, noNaN: true }),
          fc.array(fc.float({ min: 0, max: 1, noNaN: true }), {
            minLength: 1,
            maxLength: 10,
          }),
          (current, schedule) => {
            const sortedSchedule = schedule.sort((a, b) => a - b);
            const result = getNextUserFraction(current, sortedSchedule);

            // プロパティ: 結果はスケジュール内の値または現在値
            const isCurrentValue = result === current;
            const isInSchedule = sortedSchedule.includes(result);

            expect(isCurrentValue || isInSchedule).toBe(true);
          },
        ),
      );
    });
  });
});

完成したカスタムアクションを組み込んだGitHub Actionsのワークフローは以下のようになります。

update-google-play-rollout.yml
name: Update Google Play Rollout

on:
  schedule:
    - cron: '0 0 * * *'  # 毎日日本時間9時頃に実行

jobs:
  update-google-play-rollout:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: google-play-phased-release-action/package-lock.json

      - name: Install dependencies
        working-directory: google-play-phased-release-action
        run: npm ci

      - name: Run build
        working-directory: google-play-phased-release-action
        run: npm run build

      - name: Update Google Play Rollout
        id: rollout
        uses: ./google-play-phased-release-action
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_PUBLISH_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.app
          userFractions: "0.01,0.02,0.05,0.1,0.2,0.5,0.9999999"

      - name: Calculate percentages
        id: calc-percentages
        run: |
          current_fraction=${{ steps.rollout.outputs.userFraction }}
          next_fraction=${{ steps.rollout.outputs.nextUserFraction }}
          current_percentage=$(echo "scale=0; $current_fraction * 100" | bc | sed 's/^\./0./')
          next_percentage=$(echo "scale=0; $next_fraction * 100" | bc | sed 's/^\./0./')
          echo "current_percentage=${current_percentage}%" >> $GITHUB_OUTPUT
          echo "next_percentage=${next_percentage}%" >> $GITHUB_OUTPUT

      - name: Post phased release status to Slack
        if: steps.calc-percentages.outputs.current_percentage != steps.calc-percentages.outputs.next_percentage
        uses: slackapi/slack-github-action@v2.1.1
        with:
          token: ${{ secrets.SLACK_BOT_TOKEN }}
          method: chat.postMessage
          payload: |
            channel: "xxx"
            text: |
              *段階的リリース自動更新* 📱

              🔄 公開率を更新しました: ${{ steps.calc-percentages.outputs.current_percentage }} -> ${{ steps.calc-percentages.outputs.next_percentage }}

Tips: dry-runオプションで動作確認をしやすくする

作ったカスタムアクションをワークフローに組み込んで、果たして期待した値で公開率を設定してくれるのかを確認するために、dry-runオプションというものをつけています。
このフラグがtrueの場合は、更新処理は実行せずに、次に更新される公開率をログに出力するだけにしています。
依存ライブラリを更新した時に、dry-runモードで動作確認をすることで、より実際の運用に近い形で動作確認ができるようになりました。

    if (dryRun) {
      core.info(
        `✅ DRY RUN: Would update user fraction from ${currentUserFraction} to ${nextUserFraction}`,
      );
    } else {
      const editId = await getEditId(packageName);
      const updatedUserFraction = await updateUserFraction(
        packageName,
        editId,
        nextUserFraction,
      );
      core.info(`✅ Updated user fraction to: ${updatedUserFraction}`);
    }

まとめ: AIを使って作りたかったものを爆速で作れ

カスタムアクションは独立したプログラムなので、AIエージェントで作らせるのに向いた題材だと思います。
AIのおかげで、気軽にカスタムアクションを作れるようになったので、今後も色々と作っていきたいと思います。

脚注
  1. 全体公開した後に重大なバグに気づいたときにも公開を中止できるように、99.99999%にするというハックを用いていました。最近のGoogle Playのアップデートで100%で公開した後も公開を中止できるようになったため、現在は100%にしています。 ↩︎

GitHubで編集を提案
カラビナテクノロジー デベロッパーブログ

Discussion