Renovate で GitHub Actions の Xcode バージョンを更新したい

## 動機

Renovate を使い始めてからというもの、Swift Package や CocoaPods の依存関係のバージョンアップをする手間が省けて、とても便利になりました。

iOS 開発とは切っても切れない **Xcode のバージョンアップ** に対しても Renovate を使ってなんとかできないか考えてみました。

個人開発では主に GitHub Actions を CI として採用しているので、この記事ではそのケースについてのみカバーしています。

## Renovate の仕組み

Renovate は各パッケージマネージャに対応するために、Renovate 本体からいくつかの部品を module として切り離しています。

- [datasource](https://docs.renovatebot.com/modules/datasource/) から パッケージの最新バージョンを得る
- [manager](https://docs.renovatebot.com/modules/manager/) が パッケージマネージャー特有のファイルを、定められた文法で編集する
- [versioning](https://docs.renovatebot.com/modules/versioning/) が パッケージマネージャー特有のバージョン命名規則を定める

Xcode についてはいずれも公式に提供されていないので、自分でなんとかする必要があります。

## 実装方法の検討

が、実はすべてをイチから自分で実装する必要はありません。

manager については Renovate から標準で提供されている regex manager を使えば、正規表現を使ってファイルを編集することができます。

versioning についても、Xcode のバージョン命名規則はほぼ semantic versioning として扱って差し支えありません。厳密な運用は semantic versioning に従っていないかもしれないですが、あくまで命名規則についてのみ気にすれば良いので、標準の semver を利用すれば良いでしょう。

ただ、datasource についてはそのまま採用できるものはありません。
というのも、GitHub Actions の Xcode バージョンは https://github.com/actions/runner-images を通じて公開されています。
このリポジトリには GitHub Actions で利用できるすべての OS のすべてのツールのバージョンやデプロイスクリプトが書かれていて情報は十分なのですが、そのままでは Xcode のバージョンだけを選択的に抜き出すことができないし、リリースのタイミングも OSごと / ツールごとに分かれているわけではないため、いつ Xcode のバージョンがアップデートされたかを知ることが難しいです。

## できたもの

というわけで、GitHub Actions の Xcode バージョンを、GitHub Releases で知らせる仕組みを作ることにしました。

先にできたものを紹介すると、以下のリポジトリの GitHub Releases を利用します。

macOS 12 用:

https://github.com/manicmaniac/github-actions-macos-12-xcode-versions

macOS 13 用 (:warning: 2023-04-16 現在 GitHub Actions で macOS 13 はまだリリースされてません):

https://github.com/manicmaniac/github-actions-macos-13-xcode-versions

このリポジトリの [GitHub Releases](https://github.com/manicmaniac/github-actions-macos-12-xcode-versions/releases) は actions/runner-images のリポジトリで Xcode バージョンが更新されたタイミングで更新されるようになっています (ただし数時間の遅延があります)。

GitHub Releases で更新が通知できるので、datasource として `github-releases` がそのまま利用できます。
具体的な設定ファイルを示すと、以下のようにして使うことができます。

```json:renovate.json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:base"
  ],
  "regexManagers": [
    {
      "fileMatch": "^\\.github/workflows/.+\\.yml$",
      "matchStrings": [
        "/Applications/Xcode_${{ currentValue }}.app/Contents/Developer"
      ],
      "depNameTemplate": "manicmaniac/github-actions-macos-12-xcode-versions",
      "datasourceTemplate": "github-releases"
    }
  ]
}
```

## 実装方法

実装は Google Apps Script と GitHub Actions workflow を組み合わせています。

### Google Apps Script

actions/runner-images の更新を検知する役割を担当します。

actions/runner-images の Atom フィードを購読して、なにかしら変更がある場合は後述の GitHub Actions workflow を実行します。1日3回実行されるように trigger を設定しています。

わざわざ Google Apps Script で実装したのは、GitHub Actions のスケジュール実行の制限を回避するためと、変更がない場合に workflow 実行を回避したかったからです。

<details><summary>ソースコード</summary>

```javascript
const FEED_URL = 'https://github.com/actions/runner-images/releases.atom';
const WORKFLOW_URLS = Object.freeze([
  'https://api.github.com/repos/manicmaniac/github-actions-macos-12-xcode-versions/actions/workflows/synchronize.yml/dispatches',
  'https://api.github.com/repos/manicmaniac/github-actions-macos-13-xcode-versions/actions/workflows/synchronize.yml/dispatches',
]);

function fetchAtomEntries(lastTimeChecked) {
  const response = UrlFetchApp.fetch(FEED_URL, {
    headers: {
      'If-Modified-Since': lastTimeChecked.toUTCString()
    }
  });
  const document = XmlService.parse(response.getContentText());
  const feedElement = document.getRootElement();
  const atom = XmlService.getNamespace('http://www.w3.org/2005/Atom');
  const entryElements = feedElement.getChildren('entry', atom);
  return entryElements.map(element => {
    const title = element.getChildText('title', atom);
    const updated = new Date(element.getChildText('updated', atom));
    return {title, updated};
  });
}

function runGitHubWorkflow(workflowURL, token) {
  const response = UrlFetchApp.fetch(workflowURL, {
    headers: {
      'Accept': 'application/vnd.github.v3+json',
      'Authorization': `Bearer ${token}`
    },
    payload: JSON.stringify({ref: 'main'})
  });
  const responseCode = response.getResponseCode();
  if (responseCode === 204) {
    console.log('Successfully kicked GitHub Actions workflow.');
  } else {
    console.log(`Failed to kick GitHub Actions workflow. It returned ${responseCode}.`);
  }
}

function main() {
  const properties = PropertiesService.getScriptProperties();
  const lastTimeChecked = new Date(properties.getProperty('LAST_TIME_CHECKED'));
  const entries = fetchAtomEntries(lastTimeChecked);
  properties.setProperty('LAST_TIME_CHECKED', new Date().toUTCString());
  console.log(`${entries.length} entries found.`);
  const newEntries = entries.filter(entry => {
    return entry.title.includes('macOS') && entry.updated >= lastTimeChecked;
  });
  console.log(`${newEntries.length} new entries found since ${lastTimeChecked}.`);
  if (newEntries.length <= 0) {
    return;
  }
  const githubToken = properties.getProperty('GITHUB_TOKEN');
  for (const workflowURL of WORKFLOW_URLS) {
    runGitHubWorkflow(workflowURL, githubToken);
  }
}
```

</details>

### GitHub Actions workflow

GitHub Actions workflow では、actions/runner-images から Xcode バージョンの変更にかかわるファイルを抜き出して自身の `mirror` ブランチにプッシュし、Git tag と GitHub Releases を作成します。
actions/runner-images の新しいリリースが出ていても Xcode バージョンに変更がない場合はよくあるので、その場合は何もせずに終了するようになっています。

▶︎ [ソースコード](https://github.com/manicmaniac/github-actions-macos-12-xcode-versions/blob/main/.github/workflows/synchronize.yml)

## まとめ

というわけで、無事 Xcode バージョンを Renovate で管理することができそうです。
実際には Xcode バージョンアップは単にバージョンを変更するだけではなく、SDK の変更に追従するためにコードを変更する必要があることが多いため完全な自動化とはいきませんが、新しいバージョンが出たらすぐテストを回してみるということが実現できるようになりました。

また GitHub Releases の機能として Atom フィードを自動的に発行できるというものがあるので、これと組み合わせると、たとえば Slack の [RSS App](https://slack.com/intl/ja-jp/help/articles/218688467) を使って Slack に Xcode の更新を通知するようなことが簡単にできました。

上にあげたリポジトリはいっぱい使われても私に特に負担はないので、ご自由にご利用ください。ただし急に止まったり壊れたりしても責任は負えないので、ちゃんとした用途には自分で実装することをお勧めします。