お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。

割とハマってだるいので今回はサンプル程度にPRの一覧を取得して個別にSlackに投げるものを作ってみます

PRが2個ある場合、出力イメージはこんな感じ。2投稿に分けて投稿します

GitHub ActionからのSlackへの投稿イメージ

API トークンの入手

まずはSlack APIを叩くためのトークンをゲットします

  1. Create an appからアプリを作成
  2. 左のメニューからFeatures -> OAuth & Permissions
  3. Scopesを設定
    1. 今回はBot Token Scopesをchat:writeとします
  4. 左のメニューからSettings -> Install App to Your Teamでアプリをインストール
  5. トークンが吐き出されるのでメモする

GitHub Actions Workflowsの作成

SECRETの設定

  1. Slack APIトークンをリポジトリのSecretsに突っ込んでおきます
  2. 名前は一旦SLACK_TOKENとします

Workflowsの作成

前提
  • PR一覧の取得には actions/github-script を利用します
    • GitHub内部の情報を抜いたり、JSで処理を組みたいときに重宝します
    • APIリファレンスが読みやすいので、使うのにはあんま苦労しないと思います
  • Slack APIを叩くのにはcurlを利用します
    • actions/github-scriptから叩くのは多分難しいです
ベースの作成

これに肉付けをしていきます

name: Post to slack example
on:
  workflow_dispatch:
jobs:
  post-slack:
    runs-on: ubuntu-latest
    steps:
PR一覧の取得

List pull requestsにある通りに進めていきます

- uses: actions/github-script@v6
  id: set-result
  with:
    result-encoding: string
    script: |
      const { data: respPulls } = await github.rest.pulls.list({
        owner: context.repo.owner,
        repo: context.repo.repo,
      });

      console.log(respPulls);
PR一覧の加工

こんなデータを取る感じで組んでいきます
API仕様は List pull requests を参照

type PullRequest = {
  id: number;
  reviewers: string[];
};

先ほど取得したrespPullsを上記の型付けになるように加工します

const getReviewersName = (requested_reviewers) => {
  return requested_reviewers.map((reviewers) => {
    return reviewers.login;
  });
};

const getPullRequests = (pulls) => {
  return pulls.map((pull) => {
    return {
      id: pull.number,
      reviewers: getReviewersName(pull.requested_reviewers),
    };
  });
};

const pulls = getPullRequests(respPulls);
Slackに投げるメッセージの作成

こんなメッセージをPRの数分組んでいきます

なお実際にSlackでメンションを作る場合はGitHubのスクリーン名とSlackのユーザー IDの突き合わせ処理が別途必要です。やり方は別途後述します

@foo @bar
https://example.com/pulls/1

やっていること

  • 上記のフォーマットでメッセージを作成
  • シェルスクリプトで配列として扱うためにBase64にエンコード
    • 改行コードが混ざっていると扱いづらいので
  • エンコードした文字列をスペース区切り文字列として連結
    • AAA BBB CCC ...みたいな
  • 最後にWorkflowsの戻り値として設定しています
const encodedMessages = pulls.reduce((messages, pull) => {
  const reviewersBuff = pull.reviewers
    .reduce((acc, cur) => {
      return `${acc}${cur} `;
    }, '')
    .replace(/ $/, '');

  const reviewers = reviewersBuff === '' ? 'レビュアー未設定' : reviewersBuff;

  const message = `${reviewers}\\nhttps://example.com/pulls/${pull.id}`;
  const encodedMessage = Buffer.from(message).toString('Base64');

  return `${messages}${encodedMessage} `;
}, '');

return encodedMessages;
curlを利用してSlack APIを叩く

やっていること

  • encodedMessages=(${{steps.set-result.outputs.result}})
    • 前項で作った文字列を配列として取得しています
  • for message in ${encodedMessages[@]}
    • foreach的なやつです
    • 改行コードがこの時点で存在すると上手くいきません
  • decoded_mes=$(echo ${message} | base64 -di)
    • ここでBase64エンコードをデコードします
  • postSlack "$decoded_mes"
    • 別引数にならないように""で固めます
  • curl叩いてるところ
    • -dの中をヒアドキュメントで展開するのが味噌です
    • 単純に文字列として扱うと変数展開が起きてJSONが壊れます
      - run: |
        postSlack() {
            local mes=$1

            curl -sS https://slack.com/api/chat.postMessage \
                -H 'Authorization: Bearer ${{ secrets.SLACK_TOKEN }}' \
                -H 'Content-Type: application/json; charset=UTF-8' \
                -d @- <<EOF
                {
                    token: "${{ secrets.SLACK_TOKEN }}",
                    channel: "#api-test",
                    text: "$mes"
                }
        EOF
        }

        encodedMessages=(${{steps.set-result.outputs.result}})

        for message in ${encodedMessages[@]}
        do
            decoded_mes=$(echo ${message} | base64 -di)
            postSlack "$decoded_mes"
        done
コード全体
name: Post to slack example
on:
  workflow_dispatch:
jobs:
  post-slack:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v6
        id: set-result
        with:
          result-encoding: string
          script: |
            const getReviewersName = (requested_reviewers) => {
              return requested_reviewers.map((reviewers) => {
                return reviewers.login;
              });
            };

            const getPullRequests = (pulls) => {
              return pulls.map((pull) => {
                return {
                  id: pull.number,
                  reviewers: getReviewersName(pull.requested_reviewers),
                }
              });
            }

            const { data: respPulls } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
            });

            const pulls = getPullRequests(respPulls);

            const encodedMessages = pulls.reduce((messages, pull) => {
              const reviewersBuff = pull.reviewers.reduce((acc, cur) => {
                return `${acc}${cur} `
              }, '').replace(/ $/, '');

              const reviewers = reviewersBuff === '' ? 'レビュアー未設定' : reviewersBuff;

              const message = `${reviewers}\\nhttps://example.com/pulls/${pull.id}`;
              const encodedMessage = Buffer.from(message).toString('Base64');

              return `${messages}${encodedMessage} `
            }, '');


            return encodedMessages;
      - run: |
          postSlack() {
            local mes=$1

            curl -sS https://slack.com/api/chat.postMessage \
                -H 'Authorization: Bearer ${{ secrets.SLACK_TOKEN }}' \
                -H 'Content-Type: application/json; charset=UTF-8' \
                -d @- <<EOF
                {
                    token: "${{ secrets.SLACK_TOKEN }}",
                    channel: "#api-test",
                    text: "$mes"
                }
          EOF
          }

          encodedMessages=(${{steps.set-result.outputs.result}})

          for message in ${encodedMessages[@]}
          do
              decoded_mes=$(echo ${message} | base64 -di)
              postSlack "$decoded_mes"
          done

Appendix:Slackにメンションを投げる方法

Slackへ実際にメンションを投げるのはユーザーIDを指定する必要があります
参考:Formatting text for app surfaces

"text": "<@U024BE7LH> Hello"のようにすることでメンションを投げられます

ユーザーIDはSlackアプリから相手のプロフィールを開き、そこにあるハンバーガーメニューみたいなやつから取れます。一応取得用のAPIもあります

ユーザーIDをSlackアプリ画面から取得する方法

投稿日:
開発::自動化サービス::GitHub::GitHub Actions

About custom actionsに書いてあることほぼそのままですが、読みづらいのでメモがてら
どうもカスタムアクションからjsを呼び出して使うの、バンドルする必要があるようでかなりだるそうなので実用性は微妙かも…

フォルダ構成

この説明ではtest.yamlをworkflowとし、index.jsを蹴るためのサンプルで説明します

└─.github/
   └─workflows/
       ├─.actions/
       │   ├─action.yaml
       │   └─index.js
       └─test.yaml

サンプルコード

test.yaml

workflow本体です

name: test
on:
  workflow_dispatch:
jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: ./.github/workflows/actions
        with:
          param1: 'xxx'

action.yaml

custom actionです。
ファイル名はaction.ymlないしaction.yamlである必要があります。
これ以外のファイル名の場合、実行時に次のエラーが出ます。

Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '/home/runner/work/ci-test/ci-test/.github/workflows/actions'. Did you forget to run actions/checkout before running your local action?
name: test actions
description: test
runs:
  using: 'node16'
  main: 'index.js'

index.js

console.log(123);

条件分岐でコンポーネントの出し分けをしている時に正しくコンポーネントが出ているかどうかに使えるやつ
UIロジックのリグレッションテストで使える
分岐結果の出力を見てるだけなのでテストとして壊れづらく、運用しやすいと考えている

確認環境

Next.jsで確認してるけど素のReactでも同じだと思う

Env Ver
@swc/core 1.2.133
@swc/jest 0.2.17
jest 27.4.7
next 12.0.8
react 17.0.2
react-dom 17.0.2
react-test-renderer 17.0.2
typescript 4.5.4

テスト対象

テストのためにコンポーネントを細かくexportすると名前空間が汚染されるのが悩み…

type BaseProps = {
  id: string;
};

type SwitchExampleProps = BaseProps & {
  display: 'Foo' | 'Bar';
};

export const Foo = (props: BaseProps) => {
  return (
    <div id={props.id}>
      <p>Foo</p>
    </div>
  );
};

export const Bar = (props: BaseProps) => {
  return (
    <div id={props.id}>
      <p>Bar</p>
    </div>
  );
};

export const SwitchExample = (props: SwitchExampleProps) => {
  if (props.display === 'Foo') {
    return <Foo id={props.id} />;
  } else {
    return <Bar id={props.id} />;
  }
};

テストコード

react-testing-libraryの.toHaveAttribute().toHaveDisplayValue()を書き連ねるより圧倒的に楽で保守性も良いと思う

import TestRenderer from 'react-test-renderer';
import { Bar, Foo, SwitchExample } from './SwitchExample';

type TestCase = {
  name: string;
  param: Parameters<typeof SwitchExample>[0];
  actual: JSX.Element;
};

describe('SwitchExample', () => {
  const testCaseItems: TestCase[] = [
    {
      name: 'Foo',
      param: {
        id: 'hoge',
        display: 'Foo',
      },
      actual: <Foo id={'hoge'} />,
    },
    {
      name: 'Bar',
      param: {
        id: 'piyo',
        display: 'Bar',
      },
      actual: <Bar id={'piyo'} />,
    },
  ];

  testCaseItems.forEach((item) => {
    // eslint-disable-next-line jest/valid-title
    it(`switched condition ${item.name}`, () => {
      const result = TestRenderer.create(
        <SwitchExample id={item.param.id} display={item.param.display} />
      );
      const actual = TestRenderer.create(<>{item.actual}</>);

      expect(result.toJSON()).toStrictEqual(actual.toJSON());
    });
  });
});

参考記事

投稿日:
サービス::GitHub::GitHub Actions開発::自動化

GitHub Actionsで前のジョブの結果を後続で利用したい時に使えるやつ

サンプルコード

  • 適当な文字列を変数にセットして、各ジョブで出力する例
name: outputs sharing example
on:
  pull_request:
jobs:
  first:
    runs-on: ubuntu-latest
    outputs:
      # <out-job-name>: ${{ steps.<step-id>.outputs.<in-job-name> }}
      baz: ${{ steps.foo.outputs.bar }}
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - id: foo
        # この場合、FOO-BARという値がセットされる
        run: echo "::set-output name=bar::$(echo FOO-BAR)"
      - id: test
        # steps.<step-id>.outputs.<in-job-name>
        run: echo "${{ steps.foo.outputs.bar }}"
  second:
    needs: [first]
    runs-on: ubuntu-latest
    steps:
      - id: test
        # needs.<job-id>.outputs.<out-job-name>
        run: echo "${{ needs.first.outputs.baz }}"

参考

投稿日:
ソフトウェア::Git開発::設計開発::自動化

これは、かつて参画したGitHubを利用したプロジェクトでブランチフローが悪く事故が多発したので考案し、運用した内容です。
基本的には後々の運用を考えた時に情報源になり、かつGit操作に極力手間を取られることがなく、CI/CDを回しながら品質を維持できる内容で考えています。

フローの要件として考えたこと

取り回しが単純明快であること

開発以外の要素に振り回されないように単純なフローにしていて、世に言われる履歴の綺麗さとか言うのは個人的には関心が薄いので重視していません。その代わりコミットが壊れないことや、インデックスとして見やすくなるような部分を重視しています。

GitHubとの相性が良いこと

まず個々の開発タスクをIssueベースで管理し、Pull Requestベースで取り込む運用としました。
Pull Requestの取り込み方式はSquash Mergeとし、メインブランチのコミット履歴がPull Requestのマージコミットになるようにしました。
これはメインブランチのコミット履歴はPull Requestのマージコミットだけあれば後から追えるというのと、メインブランチのコミットログを単純にする意味でこの方式にしています。

CI/CDを活用しやすいこと

これは割とどこでもやっていると思いますが、ブランチ名にdevelopとかstaging, productionとか付けて管理することで、自動的に環境を識別できるようにしました。

実際に運用したフロー

フロー図

運用したブランチフロー

フローの運用内容

  • develop/mainブランチを最新ブランチとする運用
    • develop/mainブランチ相当のものが複数ある状態というのが世の中にあると思いますが、管理が非常に大変なのでそれはしない方向にしました
  • 機能ブランチをdevelop/mainブランチに取り込むのはSquash Merge
    • 基本的に変更履歴を見る時はGitLensやblameで変更行からPull Requestを当てて、そこを見に行くという運用にしていました
  • 機能ブランチにdevelop/mainブランチを取り込むのはmerge
    • 一般的にはrebaseが多いと思いますが、次の観点から採用しませんでした
      • どのポイントで取り込んだのかわからない
      • mergeと比較した場合にコンフリクト対応に手を取られる
        • 経験上ここで事故が頻発する
      • 素直にpush出来ない
    • mergeであれば以下のように単純な流れに出来ますし、push前に差分確認して事故を防ぐことも容易です
      • git switch develop/main
      • git pull
      • git switch -
      • git merge -
      • コンフリクトがあれば解消
      • git push
  • デプロイ方式によるルートブランチ分割
    • ルートブランチ名によってデプロイ先を変更できるようにしました
    • GitHub Actionsでブランチ名を拾って環境変数を差し替えることでデプロイするワークフローを組んでいます
    • staging/production/ブランチはdevelop/mainからcheckoutする運用です
      • これらはデプロイするためだけの使い捨てブランチなので毎回生えます

あとがき

このフローの利点としてはマージでコンフリクトが起きても基本的にCurrentとIncomingを一回比較するだけで済むのでコンフリクトの解消が簡単で、コンフリクト時はIncomingが壊れないようにCurrentを直すのが基本になり、Currentを優先する場合は適宜上書きするといった内容です。

ブランチの合流はmergeだとマージコミット分一回の解消だけでいいので事故の発生要因が低いのがrebaseと比較した時の利点です。rebaseだとコミット回数分再帰的に合流させる必要があるのでブランチの寿命が長かったりすると苦行になってきます。

このフローができた経緯としては元々はGitHub Flowをベースにしていたのですが、色々やっていくうちにこうしたらもっと良くなるのではないか?というのを試行錯誤していてこの形に落ち着いたのですが、後から調べたらGitLab Flowに近い形式に見えたので、似た内容は既に誰かが考えているものだなと感じました。

上で挙げた内容の他にもGitHubのリポジトリ設定でブランチ保護のルールを設定したり、Pull Requestのマージ設定でSquash mergingだけ許可したり、ヘッドブランチの自動削除をするなど、基本的に面倒なことを考えたり、しなくて良い様にするなど、開発に注力しやすいように環境を整えると心理的な抵抗が少なく運用できて便利だなと感じています。