GitHub:放置ブランチの自動削除処理

GitHub:放置ブランチの自動削除処理

複数人で開発しているリポジトリでは、不要になったデータが片付けられず放置されてしまうこともあります。

放置されているプルリクエストを掃除するには、以下のアクションを利用するのが一般的です。このアクションでは、一定期間経過したプルリクエストやイシューを自動的にクローズすることができます。

https://github.com/actions/stale

我々のリポジトリでは、プルリクエスト、イシューに加えて、放置ブランチも大きな問題で、10,000 を超える放置ブランチが存在していました。

今回、放置ブランチを自動削除する仕組みを作成したので紹介します。

説明 #

仕組みのポイント #

GitHub 上のリモートブランチは一度削除すると基本的に復元することはできません。本来削除してはいけないブランチを誤って削除してしまった場合、開発者がローカルにブランチを保持していない限り復旧は困難です。

この点がプルリクエストやイシューと異なる点です。プルリクエストやイシューは一度閉じても再度開くことができるため、比較的気軽にクローズしても構いません。

対策として、放置ブランチを直接削除するのではなく、放置ブランチから main ブランチに対してプルリクエストを作成し、一定期間経過後にそのプルリクエストをクローズしながらブランチを削除する仕組みとしました。

GitHub の場合、一度プルリクエストとして作成すると、そのブランチが削除されてもプルリクエスト自体は残ります。プルリクエストページ内には、「Restore branch」というボタンがあり、これをクリックすることで削除されたブランチを復元することができます。

Restore branch は原則無期限で利用可能です。

The branches will be restorable indefinitely, unless the repository cache is cleared.

We don’t clear repository caches automatically (we’re in the version control business, so we don’t delete data unless we absolutely have to 😉) so usually the only reason we would do that is if we had someone writing in to us asking for us to clear them

https://github.com/orgs/community/discussions/23262

なお、もし「Restore branch」ができなくなったとしても、プルリクエスト上のコード差分(Files changed タブ)からコード内容を確認することができるため、それをコピー&ペーストすれば実質的に復元することが可能です。

処理の流れ #

定期実行(下記のコード例では1日1回)するスケジュールワークフローで以下を実施します。

  1. 最終コミットから一定期間経過したブランチを特定する。ただしリポジトリの設定により保護されているブランチ(Protected Branches)は対象外とする。
  2. それらのブランチから main ブランチに対してプルリクエストを作成する。作成したプルリクエストに「Stale」ラベルを付与する。そのブランチに最後にコミットした人が GitHub 上で特定できればプルリクエストにアサインする。
  3. https://github.com/actions/stale を利用して、プルリクエストやイシューを自動クローズする。このとき上記で作成したプルリクエストも対象となり、「Stale」ラベル付与後に一定期間経過すると自動的にクローズしつつブランチも削除される。

放置ブランチを自動的削除する GitHub Action #

.github/workflows/stale.yaml #

name: Stale PRs, Issues, and Branches

on:
  workflow_dispatch:
  schedule:
    - cron: "0 0 * * *"

defaults:
  run:
    shell: bash

env:
  STALE_LABEL: "Stale"
  DAYS_BEFORE_STALE: 60
  DAYS_BEFORE_CLOSE: 14
  OPERATIONS_PER_RUN: 1000

jobs:
  open-stale-branch-prs:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout
        with:
          fetch-depth: 0
          filter: blob:none
      - name: Fetch all remote branches
        run: git fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*'
      - name: Open PRs for stale branches
        uses: ./.github/actions/stale-branches
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          default-branch: ${{ github.event.repository.default_branch }}
          days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
          days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
          operations-per-run: ${{ env.OPERATIONS_PER_RUN }}
          stale-branch-pr-label: ${{ env.STALE_LABEL }}

  stale-prs-issues:
    runs-on: ubuntu-latest
    needs: [setup-stale-label]
    if: ${{ !cancelled() }}
    permissions:
      contents: write
      pull-requests: write
      issues: write
    steps:
      - uses: actions/stale
        with:
          days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
          days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
          operations-per-run: ${{ env.OPERATIONS_PER_RUN }}
          ascending: true
          delete-branch: true

          stale-pr-label: ${{ env.STALE_LABEL }}
          stale-pr-message: "This PR is stale because it has been open ${{ env.DAYS_BEFORE_STALE }} days with no activity. Remove stale label or this will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days."
          close-pr-message: "This PR was closed because it has been stalled for ${{ env.DAYS_BEFORE_CLOSE }} days with no activity."

          stale-issue-label: ${{ env.STALE_LABEL }}
          stale-issue-message: "This issue is stale because it has been open ${{ env.DAYS_BEFORE_STALE }} days with no activity. Remove stale label or this will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days."
          close-issue-message: "This issue was closed because it has been stalled for ${{ env.DAYS_BEFORE_CLOSE }} days with no activity."

.github/actions/stale-branches/action.yaml #

JavaScript アクションで作成しています。

name: Stale Branches
description: Open PRs for stale branches

inputs:
  github-token:
    description: GITHUB_TOKEN for reading branches, deleting branches, and opening PRs.
    required: true
  default-branch:
    description: The repository's default branch.
    required: true
  days-before-stale:
    description: Idle number of days before marking branches stale
    required: true
  days-before-close:
    description: Idle number of days before closing stale branches
    required: true
  operations-per-run:
    description: Max number of operations per run
    required: true
  stale-branch-pr-label:
    description: Label applied to PRs opened for stale branches.
    required: true

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

.github/actions/stale-branches/src/index.ts #

TypeScript です。これをトランスパイルして dist/index.js を生成します。

import { execFile } from "node:child_process";
import { promisify } from "node:util";

import * as core from "@actions/core";
import * as github from "@actions/github";

const execFileAsync = promisify(execFile);

interface BranchInfo {
  name: string;
  timestamp: number; // unix seconds
  sha: string;
}

async function listRemoteBranches(): Promise<BranchInfo[]> {
  const { stdout } = await execFileAsync(
    "git",
    [
      "for-each-ref",
      "--sort=committerdate",
      "--format=%(refname:short)|%(committerdate:unix)|%(objectname)",
      "refs/remotes/origin/",
    ],
    { maxBuffer: 1024 * 1024 * 1024 },
  );
  const branches: BranchInfo[] = [];
  for (const line of stdout.split("\n")) {
    if (!line) continue;
    const parts = line.split("|");
    const remoteRef = parts[0];
    const ts = parts[1];
    const sha = parts[2];
    if (!remoteRef || !ts || !sha) continue;
    branches.push({
      name: remoteRef.replace(/^origin\//, ""),
      timestamp: Number.parseInt(ts, 10),
      sha,
    });
  }
  return branches;
}

// Test whether a ref matches a GitHub ruleset ref condition pattern.
// The pattern is fnmatch (File::FNM_PATHNAME):
// - `*` matches any character except `/`
// - `?` matches a single character except `/`
// - `**/` matches zero or more directory segments (so `a/**/*` also matches
//   `a/b`, with no extra slash)
// - a trailing `**` matches across `/`.
function refMatchesPattern(ref: string, pattern: string): boolean {
  // `~ALL` matches everything.
  if (pattern === "~ALL") return true;
  let source = "";
  for (let i = 0; i < pattern.length; i += 1) {
    const char = pattern[i] as string;
    if (char === "*" && pattern[i + 1] === "*") {
      if (pattern[i + 2] === "/") {
        // `**/` matches zero or more directory segments.
        source += "(?:[^/]+/)*";
        i += 2;
      } else {
        // `**` matches across `/`.
        source += ".*";
        i += 1;
      }
    } else if (char === "*") {
      source += "[^/]*";
    } else if (char === "?") {
      source += "[^/]";
    } else {
      source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }
  }
  return new RegExp(`^${source}$`).test(ref);
}

async function listProtectedBranches(
  octokit: ReturnType<typeof github.getOctokit>,
  owner: string,
  repo: string,
  branchNames: string[],
): Promise<Set<string>> {
  const { data: rulesets } = await octokit.request(
    "GET /repos/{owner}/{repo}/rulesets",
    { owner, repo, includes_parents: true, per_page: 100 },
  );

  const protectedRefPatterns: { include: string[]; exclude: string[] }[] = [];
  for (const ruleset of rulesets) {
    if (ruleset.target !== "branch") continue;
    if (ruleset.enforcement !== "active") continue;
    // Fail closed: if any ruleset's details cannot be fetched, abort rather than
    // risk treating a protected branch as unprotected and deleting it.
    const { data: detail } = await octokit.request(
      "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
      { owner, repo, ruleset_id: ruleset.id, includes_parents: true },
    );
    const refName = detail.conditions?.ref_name;
    protectedRefPatterns.push({
      include: refName?.include ?? [],
      exclude: refName?.exclude ?? [],
    });
  }

  const protectedBranches = new Set<string>();
  for (const branch of branchNames) {
    const ref = `refs/heads/${branch}`;
    const isProtected = protectedRefPatterns.some((patterns) => {
      const included = patterns.include.some((pattern) =>
        refMatchesPattern(ref, pattern),
      );
      if (!included) return false;
      const excluded = patterns.exclude.some((pattern) =>
        refMatchesPattern(ref, pattern),
      );
      if (!excluded) return true;
      return false;
    });
    if (isProtected) protectedBranches.add(branch);
  }

  return protectedBranches;
}

async function countCommitsAhead(
  defaultBranch: string,
  branch: string,
): Promise<number> {
  const { stdout } = await execFileAsync("git", [
    "rev-list",
    "--count",
    `origin/${defaultBranch}..origin/${branch}`,
  ]);
  return Number.parseInt(stdout.trim(), 10);
}

function formatUtcSeconds(unixSeconds: number): string {
  // Format as YYYY-MM-DDTHH:MM:SSZ (drop milliseconds).
  return new Date(unixSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
}

function errorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  return String(error);
}

try {
  const githubToken = core.getInput("github-token", { required: true });
  const defaultBranch = core.getInput("default-branch", { required: true });
  const daysBeforeStale = Number.parseInt(
    core.getInput("days-before-stale", { required: true }),
    10,
  );
  const daysBeforeClose = Number.parseInt(
    core.getInput("days-before-close", { required: true }),
    10,
  );
  const operationsPerRun = Number.parseInt(
    core.getInput("operations-per-run", { required: true }),
    10,
  );
  const staleBranchPrLabel = core.getInput("stale-branch-pr-label", {
    required: true,
  });

  if (
    !Number.isFinite(daysBeforeStale) ||
    !Number.isFinite(daysBeforeClose) ||
    !Number.isFinite(operationsPerRun)
  ) {
    throw new Error(
      "days-before-stale, days-before-close, and operations-per-run must be integers.",
    );
  }

  const { owner, repo } = github.context.repo;
  const octokit = github.getOctokit(githubToken);
  let apiCallCount = 0;
  octokit.hook.before("request", () => {
    apiCallCount += 1;
  });

  const cutoffSec =
    Math.floor(Date.now() / 1000) - daysBeforeStale * 24 * 60 * 60;

  const branches = await listRemoteBranches();
  core.info(`Remote branches count: ${branches.length}`);

  const protectedBranches = await listProtectedBranches(
    octokit,
    owner,
    repo,
    branches.map(({ name }) => name),
  );
  core.startGroup(`Protected branches`);
  for (const protectedBranch of protectedBranches) {
    core.info(protectedBranch);
  }
  core.endGroup();

  for (const { name: branch, timestamp, sha } of branches) {
    if (apiCallCount >= operationsPerRun) {
      core.info(`Reached operations-per-run=${operationsPerRun}.`);
      break;
    }

    // Skip fresh branches.
    if (timestamp >= cutoffSec) continue;

    // Skip HEAD and the default branch.
    if (branch === "HEAD" || branch === defaultBranch) continue;

    // Skip protected branches.
    if (protectedBranches.has(branch)) {
      core.info(`Skipping ${branch}: branch is protected.`);
      continue;
    }

    // Skip if an open PR already exists from this branch.
    try {
      const { data: openPrs } = await octokit.rest.pulls.list({
        owner,
        repo,
        state: "open",
        head: `${owner}:${branch}`,
        per_page: 1,
      });
      const existing = openPrs[0];
      if (existing) {
        core.info(
          `Skipping ${branch}: open PR #${existing.number} already exists.`,
        );
        continue;
      }
    } catch (error) {
      core.warning(
        `Skipping ${branch}: failed to check for open PRs. ${errorMessage(error)}`,
      );
      continue;
    }

    // Delete directly if the branch has no commits ahead of the default branch.
    let ahead: number;
    try {
      ahead = await countCommitsAhead(defaultBranch, branch);
    } catch (error) {
      core.warning(
        `Skipping ${branch}: failed to count commits ahead of ${defaultBranch}. ${errorMessage(error)}`,
      );
      continue;
    }
    if (ahead === 0) {
      core.info(`Deleting ${branch}: no commits ahead of ${defaultBranch}.`);
      try {
        await octokit.rest.git.deleteRef({
          owner,
          repo,
          ref: `heads/${branch}`,
        });
      } catch (error) {
        core.warning(
          `Failed to delete branch ${branch}. ${errorMessage(error)}`,
        );
      }
      continue;
    }

    // Look up the last committer's GitHub username.
    let assignee = "";
    try {
      const { data: commit } = await octokit.rest.repos.getCommit({
        owner,
        repo,
        ref: sha,
      });
      assignee = commit.author?.login ?? commit.committer?.login ?? "";
    } catch {
      // Treat as unknown committer; proceed without an assignee.
    }

    // Drop the assignee if the user cannot be used as an assignee.
    if (assignee) {
      try {
        await octokit.rest.issues.checkUserCanBeAssigned({
          owner,
          repo,
          assignee,
        });
      } catch {
        assignee = "";
      }
    }

    const title = `Stale branch: ${branch}`;
    const committedAt = formatUtcSeconds(timestamp);
    const body = [
      `This branch is stale because it has not been updated for ${daysBeforeStale} days. Remove \`${staleBranchPrLabel}\` label or this branch will be deleted in ${daysBeforeClose} days.`,
      "",
      `- Branch: \`${branch}\``,
      `- Last commit: \`${sha}\``,
      `- Last commit date: ${committedAt}`,
      `- Last committer: ${assignee ? `@${assignee}` : "(unknown)"}`,
    ].join("\n");

    let prNumber: number;
    try {
      const { data: pr } = await octokit.rest.pulls.create({
        owner,
        repo,
        base: defaultBranch,
        head: branch,
        title,
        body,
        draft: true,
      });
      prNumber = pr.number;
      core.info(`Opened PR #${prNumber} for stale branch ${branch}.`);
    } catch (error) {
      core.warning(
        `Failed to open PR for stale branch ${branch}. ${errorMessage(error)}`,
      );
      continue;
    }

    try {
      await octokit.rest.issues.addLabels({
        owner,
        repo,
        issue_number: prNumber,
        labels: [staleBranchPrLabel],
      });
    } catch (error) {
      core.warning(
        `Failed to apply label "${staleBranchPrLabel}" to PR #${prNumber}. ${errorMessage(error)}`,
      );
    }

    if (assignee) {
      try {
        await octokit.rest.issues.addAssignees({
          owner,
          repo,
          issue_number: prNumber,
          assignees: [assignee],
        });
      } catch (error) {
        core.warning(
          `Failed to assign ${assignee} to PR #${prNumber}. ${errorMessage(error)}`,
        );
      }
    }
  }

  core.info(`Done. Performed ${apiCallCount} API calls.`);
} catch (error) {
  core.setFailed(errorMessage(error));
}

.github/actions/stale-branches/dist/index.js #

先の TypeScript をトランスパイルして生成された JavaScript をここに配置します。ここに配置したファイルが JavaScript アクションから呼び出されます。

(TypeScript をトランスパイルしただけです。ファイル内容は省略します。)