PowerCMS XとGitHub ActionsでCSS・JavaScriptのCI/CDパイプラインを試作

公開

Webサイト制作時、CSS設計ではSass・PostCSS・BEMやFLOCSS等を、JavaScript開発においてもモジュール設計やwebpack等のツールを利用することが多いのではないかと思います。そのような開発環境において、フロントエンド開発者が出力したCSSやJavaScriptをPowerCMS Xの管理画面で気軽に編集されるとコード管理や品質管理上困ってしまいます。また、コードをリリースする度に管理画面にコピーアンドペーストするのも手間に感じます。

そこで、コーディングデータを管理するリポジトリを利用し、GitHub Actionsでワークフローを実行してCSS・JavaScriptの成果物を生成し、その成果物をPowerCMS Xで展開するCI/CDパイプラインを試作してみました。(CI/CDパイプラインと言うのは少し大げさかも、と思いつつ…)もしかすると「GitHubのワークフローで直接サーバーに配置しても良いのでは?」と思われるかもしれませんが、PowerCMS XのURLオブジェクトがないとプレビュー時にファイルが利用できない(HTTP 404エラーとなる)、AWS_S3プラグインAWS_CloudFrontプラグインとの連携ができない、などの制限が発生するので本プラグインが必要となります。

Webhookの設定

GitHubのリポジトリ設定でWebhookを追加します。トリガーするイベントは「Workflow runs」を選択し、ワークフローが完了した際に情報を受け取ることができるようにします。

ワークフローの設定

GitHub Actionsで実行するワークフローを.github/workflow/ci.ymlに記述します。今回はSassファイルからCSSを生成し、Stylelintを実行した後でdistディレクトリを成果物としてまとめます。ci.ymlの記述例は「ワークフロー データを成果物として保存する - GitHub Docs」で紹介されており、これを調整して利用しました。

ワークフローの実行が成功すると下記のように成果物が生成されています。
GitHubでワークフローを表示した画面のキャプチャ

Webhookを受信してアセットをワークスペース内に配置する

現在ローカル環境で開発しているため、ngrokを利用してWebhookを受信します。受信したWebhook本文にはワークフローの成否とコミットハッシュ、成果物情報のURL等が入っています。以下がサンプルです。(後で出てくる9:49頃のコミット分ではありませんのでご了承ください。)

{
    "action": "completed",
    "workflow_run": {
        "id": 7827503653,
        "name": "Node CI",
        "node_id": "WFR_kwLOLPhz4M8AAAAB0o46JQ",
        "head_branch": "main",
        "head_sha": "f647348ad4798a2132bd20c876827c39de95ccaa",
        "path": ".github/workflows/ci.yml",
        "event": "push",
        "status": "completed",
        "conclusion": "success",
        "workflow_id": 85323965,
        "artifacts_url": "https://api.github.com/repos/hideki-a/pcmsx-asset-release-dev/actions/runs/7827503653/artifacts",

これを基に成果物のZipファイルを取得して展開します。その後、ディレクトリ内のファイルリストを取得し、URLオブジェクトを作成・更新してPTFileMgrクラスのメソッドを実行しファイルを配置します。URLオブジェクト内にMD5を保存しているので、新規ファイルと更新ファイルのみ処理されます。

試作段階のコードは以下の通りです。

<?php

declare(strict_types=1);

namespace AssetRelease;

use Prototype;

class Distributor
{
    private $app;
    private $workspace;
    private $workDir;

    public function __construct(int $artifactsId)
    {
        // FIXME: 汎用化する
        // unset($app->hooks['take_down']); // (S3, CloudFrontを停止)
        $workspace_id = 14;
        $workBaseDir = '/Users/Shared/Sites/powercmsx/support/asset_release';

        $app = Prototype::get_instance();

        $this->app = $app;
        $this->workDir   = $workBaseDir . DS . $artifactsId;
        $this->workspace = $app->db->model('workspace')->load($workspace_id);
    }

    private function scanFiles(): array
    {
        // NOTE: ここはディレクトリを操作してファイルをリストアップする予定
        return [
            'css/main.css',
        ];
    }

    private function makeFileUrl(string $filePath): string
    {
        $siteUrl  = $this->workspace->site_url;
        return $siteUrl . $filePath;
    }

    private function makeFileAbsolutePath(string $filePath): string
    {
        $sitePath = $this->workspace->site_path;
        return "{$sitePath}/{$filePath}";
    }

    private function setUrlInfo(string $filePath, string $md5, bool $published): bool
    {
        $urlInfoUpdated = false;

        $fileUrl  = $this->makeFileUrl($filePath);
        $fileAbsolutePath = $this->makeFileAbsolutePath($filePath);

        $urlInfo = $this->app->db->model('urlinfo')->get_by_key([
            'url' => $fileUrl,
            'workspace_id' => $this->workspace->id,
        ]);

        if (!$urlInfo->id) {
            $urlInfo->dirname(preg_replace('/(.*\/).*\.\w+$/', '$1', $fileUrl));
            $urlInfo->relative_url(preg_replace('/https?:\/\/[^\/]+/', '', $fileUrl));
            $urlInfo->relative_path("%r/$filePath");
            $urlInfo->class('archive');
            $urlInfo->urlmapping_id(0);
            $urlInfo->file_path($fileAbsolutePath);
            $urlInfo->is_published($published);
            $urlInfo->delete_flag(!$published);
            $urlInfo->was_published(1);
            $urlInfo->md5($md5);
            $urlInfo->save();

            $urlInfoUpdated = true;
        } elseif ((int) $urlInfo->is_published !== (int) $published) {
            $urlInfo->is_published($published);
            $urlInfo->delete_flag(!$published);
            $urlInfo->save();

            $urlInfoUpdated = true;
        } elseif ($urlInfo->md5 !== $md5) {
            $urlInfo->md5($md5);
            $urlInfo->save();

            $urlInfoUpdated = true;
        }

        return $urlInfoUpdated;
    }

    public function run()
    {
        // 追加・変更されたファイルの処理
        $filePaths = $this->scanFiles();
        foreach ($filePaths as $filePath) {
            $md5 = md5_file($this->workDir . DS . $filePath);
            $urlInfoUpdated = $this->setUrlInfo($filePath, $md5, true);
            $fileAbsolutePath = $this->makeFileAbsolutePath($filePath);

            if ($urlInfoUpdated) {
                $data = file_get_contents($this->workDir . DS . $filePath);
                $this->app->fmgr->put($fileAbsolutePath, $data);
            }
        }

        // TODO: 削除されたファイルの処理
        // if ($urlInfoUpdated) {
        //     $this->app->fmgr->delete($fileAbsolutePath);
        // }
    }
}

main.scssを更新してGitHubへのプッシュを9:49:00頃に行い、ワークフローは25秒で完了しました。まずURLオブジェクトが更新されます。
URLオブジェクトを表示した画面のキャプチャ

ワークフローの成果物をsupport/asset_releaseディレクトリにダウンロードして展開しました。
Finderで成果物のあるディレクトリを表示した画面のキャプチャ

ワークスペースのサイト・パス内にmain.cssが配置されました。
Finderでmain.cssのあるディレクトリを表示した画面のキャプチャ

また、AWS_S3プラグインにより同期が実行されました。
AWSのコンソールでバケット内のファイルを表示した画面のキャプチャ

AWS_CloudFrontプラグインによりキャッシュの無効化リクエストを行うキューも生成されました。
キャッシュの無効化リクエストを表示した画面のキャプチャ

ここまで全てGitHubへのプッシュ操作だけで完結できました。

今後の課題

ファイルの削除に対応しなければならないのですが、distディレクトリに含まれなくなったファイルはコミットログからは分からないので、前回の成果物ディレクトリと比較してリストを抽出しようかと考えています。

本番環境はこのプラグインで処理が完結できそうですが、開発環境だと作業毎にブランチを分けることがあり、どのように成果物を配置するのが良いのだろう?と考えます。そもそも、管理画面にコピーアンドペーストする際もみなさんどのようにされているのか、お話を伺いたいところです。VercelのようにプレビューURLが発行されるとか、なにか追加の仕掛けが欲しくなります。

ビュー(HTML・MTML)を変更した時にどうするのか、も検討の余地ありです。Theme_GitHubプラグインを併用して別々に適用するのか、等が思い浮かびます。コードを安全に、かつできるだけ簡単に、できれば自動でリリースしたいのです。

※ビューもファイルで記述・管理したい派だけど、管理画面に書くからこそ複数チケットの確認が1つの開発環境でできる、というのはありそうです。昔からある開発手法と今風の開発手法のせめぎ合い。