Corredor

ウェブ、プログラミングの勉強メモ。

GitHub Actions から FTP 転送する

自分のメインサイト Neo's World は GitHub で管理しているが、ファイルの FTP 転送は手動で行っていた。

github.com

一応 npm スクリプトを用意してコマンドラインから FTP 転送したりできるようにはしていたが、完全自動化したくなり、GitHub Actions でコネコネすることにした。

neos21.hatenablog.com

↑ 今回も使用する ftp-client の解説。

neos21.hatenablog.com

GitHub Actions Workflow

今回作成した GitHub Actions Workflow は次のとおり。

  • .github/workflows/ftp.yaml
name: FTP
on:
  push:
    branches:
      - master
    workflow_dispatch:
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: 12.x
      # 変更があったファイルを JSON ファイルに書き出す
      - name: Get Changed Files
        id  : get_changed_files
        uses: jitterbit/get-changed-files@v1
        with:
          format: json
      - name: Write Changed Files
        run : |
          mkdir -p ./temp/
          echo '${{ steps.get_changed_files.outputs.added_modified }}' > ./temp/added_modified.json
          echo '${{ steps.get_changed_files.outputs.renamed }}' > ./temp/renamed.json
          echo '${{ steps.get_changed_files.outputs.removed }}' > ./temp/removed.json
          cat ./temp/added_modified.json
          cat ./temp/renamed.json
          cat ./temp/removed.json
      # JSON ファイルを基にアップロードするファイル名の配列を組み立て ./temp/upload-files.json に書き出す
      - name: Detect Upload Files
        id  : detect_upload_files
        run : |
          node ./.github/workflows/ftp-detect-upload-files.js
          if [[ -e ./temp/upload-files.json ]]; then
            echo "::set-output name=do_upload::true"
            echo 'Do Upload'
          else
            echo "::set-output name=do_upload::false"
            echo 'Do Not Upload'
          fi
      # 以降のデプロイ処理は do_upload Output が true の時だけ実行する
      - name: Install
        if  : steps.detect_upload_files.outputs.do_upload == 'true'
        run : npm install
      - name: Build
        if  : steps.detect_upload_files.outputs.do_upload == 'true'
        run : npm run build --if-present
      - name: FTP Upload Files
        if  : steps.detect_upload_files.outputs.do_upload == 'true'
        run : node ./.github/workflows/ftp-upload-files.js
        env :
          FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
  • master ブランチへの Push 時に起動する
  • Get Changed Files
    • jitterbit/get-changed-files@v1 Action を使い、コミットを参照して変更が発生したファイルを JSON 形式で取得する
    • JSON の形式は次のような配列形式になる
      • 対象ファイルが1件もない場合 : [] ← 空配列
      • 対象ファイルがある場合 : ["src/pages/index.html","src/styles/index.scss"] といった感じ
  • Write Changed Files
    • その内容を JSON ファイルに書き出しておく
    • 前述のとおり、対象ファイルがなくても空配列の文字列 [] が書き込まれるので、後続の Node.js スクリプト内で require() を使って読み込んでもエラーにならない
  • Detect Upload Files
    • その JSON ファイルを読み取って、FTP アップロードすべきファイル名を列挙した JSON ファイルを作る
    • ftp-detect-upload-files.js という自作の Node.js スクリプトを動かす (後述)
    • 結果ファイルの有無を基に、アップロード処理が必要かどうかを Output に出力しておく
  • アップロード処理が必要なら、npm installnpm run build 実行後、FTP アップロードを行う
  • FTP Upload Files
    • FTP アップロード処理は、ftp-client パッケージを使った自作の Node.js スクリプト ftp-upload-files.js で行っている
    • FTP パスワードを Secret で渡している

という感じ。

jitterbit/get-changed-files が要で、その後のファイル調整や FTP アップロード処理は自前でゴリゴリ実装している。

アップロードが必要なファイル名を列挙するスクリプト

「Detect Upload Files」Step で動かしている ftp-detect-upload-files.js の内容はこんな感じ。

  • ./.github/workflows/ftp-detect-upload-files.js
const fs = require('fs');

const srcDir  = 'src/';
const distDir = 'docs/';

// 前 Step で JSON ファイルに書き出しておいた変更ファイル一覧を取得する
const addedModified = require('../../temp/added_modified.json');
const renamed       = require('../../temp/renamed.json');
const removed       = require('../../temp/removed.json');  // 削除されたファイルがあるかどうかの確認だけ

if(removed.length) {
  console.log('Removed Files Exist. Please Remove Manually');
}

const joined = [...addedModified, ...renamed];

// src/templates/ 配下の変更がある場合は一切のアップロードを行わないこととする
const isTemplateChanged = joined.some((file) => file.includes(srcDir + 'templates/'));
if(isTemplateChanged) {
  console.error('Templates Changed! Please Upload Manually. Aborted');
  return process.exit(1);
}

// src/ 配下の変更ファイルのみ抽出する
const filtered = joined.filter((file) => file.includes(srcDir));

// ビルド後のコンテンツに対応するようファイル名を直す
const uploadFiles = filtered.map((file) => {
  if(file.includes(srcDir + 'scripts/')) {
    return distDir + 'scripts.js';
  }
  if(file.includes(srcDir + 'styles/')) {
    return distDir + 'styles.js';
  }
  if(file.includes(srcDir + 'pages/')) {
    return file.replace(srcDir + 'pages/', distDir);
  }
  // 知らないパスにファイルが登場したら中止する
  throw new Error(`The file path is not supported : [${file}]`);
});

// 変更ファイルがなければ結果ファイルを作らず終了する
if(!uploadFiles.length) {
  return console.log('Upload Files Not Exist');
}

const stringified = JSON.stringify(uploadFiles);
console.log('Upload Files :');
console.log(stringified);
fs.writeFileSync('./temp/upload-files.json', stringified, 'utf-8');
  • 前 Step で書き出しておいた JSON を require() で読み込んでいる

jitterbit/get-changed-files は JSON で出力するので、ファイルに書き出したりしなくとも、jq で読み込んで jq 芸をキメて、アップロードしたいファイルの列挙をしても良い。

今回はちょっと複雑なファイル名の置換処理があったので、JavaScript で処理してしまったが、.github/ ディレクトリだけはアップしたくない、ぐらいの Filter 処理なら、jq で処理して Output しても良いだろう。

  • 「変更があったファイル名」は、src/ から始まるソースコードとして特定されるが、FTP アップロードしたいのは npm run build で生成される dist/ 配下のファイルとなる
  • そこで、このスクリプト内で filter() したり map() したりして、アップロードしたい dist/ 配下のファイル名を組み立て、JSON ファイルに書き出すのである
  • ファイル削除の変更には対応していないので、削除処理は手作業でやってね、というメッセージを出力している
  • プロジェクト都合だが、全量再アップロードになる src/templates/ 配下の変更があった場合はエラーとし、自分でやってもらうようにしている。よって、変更の内容によっては完全自動で FTP 転送が完了するワケではない

アップロードしたいファイルが1件でもあれば、JSON ファイルを書き出しているので、コレをその後のシェルスクリプトでチェックして、後続処理を行うかどうかの判定用 Output を用意している。

if [[ -e ./temp/upload-files.json ]]; then
  echo "::set-output name=do_upload::true"
  echo 'Do Upload'
else
  echo "::set-output name=do_upload::false"
  echo 'Do Not Upload'
fi

以降の Step では、ココで Output した do_upload フラグを if で見ている。

- name: Install
  if  : steps.detect_upload_files.outputs.do_upload == 'true'
  run : npm install

FTP アップロードするスクリプト

FTP アップロードするスクリプトは ftp-client パッケージを利用している。

  • ./.github/workflows/ftp-upload-files.js
const FtpClient = require('ftp-client');

// 手前の Node.js スクリプトが出力した JSON ファイルを読み込む
const uploadFiles = require('../../temp/upload-files.json');

const ftpClient = new FtpClient({
  user    : '【ユーザ名】',
  password: process.env['FTP_PASSWORD'],  // Secret からパスワードを注入する
  host    : '【サーバ名】'
}, {
  logging: 'debug',
  overwrite: 'all'
});

ftpClient.connect(() => {
  console.log('Connected');
  
  ftpClient.upload(
    uploadFiles,  // ← ココで配列として指定する
    '/public_html/',
    {
      baseDir: 'docs',
      overwrite: 'all'
    },
    (result) => {
      if(result.errors && Object.keys(result.errors).length) {
        console.error('Upload Error');
        console.error(result);
        throw new Error('Upload Error');
      }
      
      console.log('Uploaded')
      console.log(result);
    }
  );
});

自分が送信対象としている XREA サーバの仕様に合わせて各種設定を入れている。FTP パスワードなどのクレデンシャル情報は Secret を環境変数に注入して使用すること。

以上

FTP 転送ができる GitHub Actions もあるのだが、自分の XREA サーバに対して上手く動かせなかったので、結局自前でゴリゴリ実装していった。

ファイル削除に対応しきれていなかったりはするが、とりあえずやりたかったことは上手くできたのでコレで OK。