LoginSignup
36
20

More than 3 years have passed since last update.

Danger から Danger-Swift 移行への手引き

Posted at

なぜ Danger-Swift に移行(しようと)したか

以前の記事にも書いたとおり、弊社はソースコードの品質担保の一環として Danger による PR の機械チェックが導入されています。通常のコードフォーマットが合ってるかどうか、ビルドワーニングが発生してるかどうか以外にも、テストのカバレッジがどれくらいか、(バージョンリリース時に)バージョン番号や Changelog がちゃんと更新されているかどうか、更にブランチの設定が正しいかどうかや、そのブランチ名からチケット番号を抽出して自動で PR ページにチケットリンクへ書き込むなどの機能も Danger で実現させています。割と Danger のヘビーユーザです。

ところが、そんな Danger ですが、もちろん多くの機能はプラグインによって実現されており、例えばその「テストのカバレッジがどれくらいか」というのはまさに Danger-slather というプラグインによって実現されています。

そして最近、社内では純粋 SwiftPM プロジェクトが新しくできています。文字通り純粋 SwiftPM 案件なので、通常の Xcode プロジェクトでお馴染みの .xcodeproj ファイルや Scheme の設定などはありません。しかし残念ながら、Danger で Xcode プロジェクトのテストカバレッジを取得してるこの Danger-slather も danger-xcov も、.xcodeproj ファイルと Scheme の設定に強く依存しています。特に Danger-slather は 4 年以上更新されておらず、danger-xcov も以前導入を試みたときはっきり覚えていませんがなぜかカバレッジをうまく読み取れなかったことが何回かあったため結局断念した記憶があります。

ここで思い出したのが Danger-Swift の存在です。Danger-Swift は Danger と全く同じ目的のものですが、Swift で実装されており、Dangerfile も Ruby ではなく Swift で記述されます。そのため Swift 信者の筆者はその当時から導入したかったのですが、Danger と Danger-Swift はプラグインがお互い互換性が全くなく、そして当時の Danger-Swift プラグインライナップは非常に貧弱だったため断念しました。

ところで今調べ直してみたら、SwiftLint はもちろん、当時まだなかったカバレッジを取得する DangerSwiftCoverage やビルドワーニングを取得する DangerXCodeSummary も揃えられたので、必要なものはとりあえず一通り揃えたと感じて今回の導入に踏み切りました。

移行してみてどうか

まだ移行したばかりなので深いところまでは突っ込めませんが、ひとまず今現在の感想を良かったところと悪かったところを分けて述べます。

良かったところ

Swift で Dangerfile 書けるので、Swift 信者に私にとっては非常にやりやすい気持ちです。

まず Swift には型がちゃんとあるので、その型に応じたプロパティーやメソッドの参照が可能になり、「この属性ってどんな型だっけ?」とか「このデータを取得するにはどこ経由でどう取ればいいんだっけ?」と言った仕様に基づいた使用は Ruby と比べて非常に楽です。何より大抵のことは型で保証してくれてますので。

そして Swift の表現力にも非常に助かります。後述しますが例えば共通の出力フォーマットを定義したいとき、とりあえず struct で定義しとけば、あとで算出プロパティーとかメソッドとかクロージャの力でいろんなことが実現可能です。(もちろんプロパティーとかは Ruby もできますが、何せ型がないのでね…)

最後はやはり使い慣れてる言語で書けるのもとてもいいところですね。正規表現の抽出とか配列の操作とか、これは普段の業務でもいつもやってることなのでどう書けばいいかすぐわかりますから、調べながら動作テストしながら書いていくしかない Ruby と比べると作業効率爆上げです(もちろんそもそも Ruby に慣れてる人でしたら Ruby の方が作業効率が高いでしょう)。

ちなみに Danger-Swift は Dangerfile.swift を編集するための Xcode プロジェクト環境を自動で生成できるので、本体プロジェクトに影響を及ぼさずに Dangerfile の編集が可能です。

悪かったところ

残念ながら Danger-Swift は実は罠もたくさん潜んでありますね。まあ Swift はそもそもスクリプト言語ではないのに無理やりスクリプトとして動かしてるから仕方ないのかもしれません。

まず意外なところですが、実は Danger-Swift は JavaScript 製の Danger ツール Danger-JS に依存しています。まだ具体的に Danger-Swift の実装を見ていないのでどこで何のために使ってるかまでは把握していませんが、ただそのせいで時々 Danger-JS のエラーが吐き出されて何が何だかよくわからないことが稀によくあります。そしてこの Danger-JS のコマンドは Ruby 製の Danger ツールと同じ danger のため、Danger-JS と Danger の共存ができず、すでに Ruby 製の Danger をインストール済みの場合は先にこれをアンインストールしなくてはいけないのです。まあ Swift は所詮スクリプト言語ではなくコンパイル言語なので、それは仕方ないのかもしれません(Danger-Swift の詳しい実装見てないので適当に言ってみただけです)。

次に Danger-JS 抜きにしても、Danger-Swift のエラーも比較的にわかりにくいことがあり、しかも Ruby 製 Danger と比べて情報が圧倒的に少ないので、何か躓いたら割とかなり難航します。

さらには Danger では git にあったいくつかのもの、例えば修正量を抽出する lines_of_code とか、は Danger-Swift の場合なぜか github とかのサービス依存のプロパティーに移動されてしまいました;その上 github プロパティーは IUO ですので、それが当たり前のように存在してる気持ちで Dangerfile.swift 書いてたら、danger-swift local で動作確認すると githubnil になって落ちちゃいます。ちなみにこのエラーもメッセージがわかりにくい(どのプロパティーが nil なのかが教えられていない)ため、私はこれの原因を突き止めるのにかなり時間かかりました。

これらの悪いところで更に次に悪かったところを引き出します:githubnil になっちゃうのに、割と多くのデータ取得が github に依存してしまうため、完全にローカルだけで完結できる動作確認(danger-swift local)が非常に限られており、結局 PR つくってそれに基づいたローカル動作確認(danger-swift pr [PR URL])が必要になります。

そして機能面でも、実は Ruby 製 Danger より低いところがあります。例えば Danger-Swift は直接特定ファイルの diff を抽出できません、それをやりたい場合は残念ながら自分で Swift から shell コマンドを呼び出す必要があります。(そして残念ながら今のところ私はまだこれを使った機能の動作確認ができていないので、本当にうまくいくかどうかの確証がまだないのです…)

最後にまた後述しますが、良かったところでは Dangerfile.swift を編集するための Xcode プロジェクト環境を自動で生成できると書いてありますが、そのための手順があるのでそれが抜けちゃうと編集が反映されないことがあります。

実際の移行手順

Danger-Swift のインストール

Danger-Swift は基本 CI 側で実行されるものですが、Dangerfile.swift ファイルの正規性を確認するためにもローカルで実行できる方が嬉しいです。

ところでここでいきなり最初の罠にハマります:悪かったところにも書きましたが Ruby 製 Danger との共存ができませんが、タイトルで書いたとおり筆者は「移行」でしたので、元々ローカルには Ruby 製の Danger がインストールされているのです。そのためこのまま brew install danger/tap/danger-swift でインストールしても、「すでに danger がありますよ」的なことが言われてインストール失敗します。そのためまず最初は既存の Ruby 製 Danger をアンインストールする必要があります。

Dangerfile.swift の作成

Danger-Swift がインストールされたら、次は確認スクリプトの Dangerfile.swift を作る必要があります。Ruby 製 Danger の場合は自分で Dangerfile を勝手につくっちゃえばいいから、Danger-Swift もまあ同じ方法でやっても問題はないのですが、実は Danger-Swift は Dangerfile.swift の簡易な構築環境も用意してくれています。danger-swift edit コマンドを叩けば、Dangerfile.swift の Xcode プロジェクトが立ち上がりますので、この時点でもし Dangerfile.swift がまだなかったら自動で最低限のものを生成してくれますし、あったらそれをそのまま使ってくれます。Swift は Playground と main.swift 以外で直接関数の呼び出しができないので、この構築環境は自動でプロジェクト内で main.swift をつくってくれます。また danger-swift edit コマンドは自動終了しないので、編集が終わったら main.swift を保存すればその内容が自動的に Dangerfile.swift に反映されてくれます。そして完全に作業が終わったら danger-swift edit を叩いたターミナルに行って return キーを押せば終了できます。

ただし注意しないといけないのは、この構築環境のプロジェクトは Dangerfile.swift のみに基づいて自動生成されるものなので、このプロジェクト内で main.swift 以外のソースファイルとか追加しても Dangerfile.swift に反映されませんし実行できません。必要なものは全て main.swift 内で完結する必要があります。

また悪かったところにも軽く書いてありますが、その作業が終わったらターミナルだけ終了して Xcode プロジェクトを開いたままにすると、今度は danger-swift edit を叩かずにそのまま開いた状態の Dangerfile プロジェクト編集しちゃっても、それを自動で Dangerfile.swift に反映される術はありません。またその修正された状態で danger-swift edit を叩くと、反映される前の古いコードの Dangerfile.swift の内容が main.swift に適用されちゃいますので、今までの編集が全て失われてしまいます。なのでここでの TIPS としては、Dangerfile プロジェクトは保存すると修正が自動的に Dangerfile.swift に反映されるので、作業が本当に終わるまでは danger-swift edit をそのまま閉じずに動かしてください。そして作業が終わったら必ず Dangerfile プロジェクトを閉じちゃった方が無難です。

プラグインの利用

Danger-Swift はあくまで CI 環境で動かすものであってプロジェクト自体にとって必須ではありません。そのため私は基本的に Danger-Swift を Package.swift とかのパッケージ管理ファイルには追加しない方が好きです。しかしそうすると今度は逆に自分の Dangerfile.swift が依存しているプラグインの管理が難しくなります。

でもご安心ください、幸いなことに Danger-Swift は Marathon(すでに Deprecated にはなってますが)を使って依存解決が可能です。方法としては Dangerfile.swiftimport の行の最後に、コメントで // package: PackageURL を書いとけばいいです。例えば DangerSwiftCoverage の導入は、下記のようなコードで書けばいいです:

import DangerSwiftCoverage // package: https://github.com/f-meloni/danger-swift-coverage.git

こうすると、danger-swift edit コマンドを叩くと、Danger-Swift は自動的に DangerSwiftCoverage を落としてきてプロジェクト内で使える状態にしてくれます。非常に便利ですし、ライブラリー配布の場合も Danger による Package.swift の不必要な依存が増える心配もないです。

ちなみにもしそれ書いても No such module のエラーが出るなら、一回 Xcode を終了してもう一回 danger-swift edit やり直してみてください、私の場合はこれで解決しました。

Dangerfile.swift の動作確認

悪かったところにも書いてありますが、残念ながら danger-swift local で確認できる動作は割と限られています。必要最小限の動作確認(例えば修正があるファイルのパスだけ出力してみる確認とか)なら danger-swift local で確認できますが、github に依存している動作確認は必ず danger-swift pr PR_URL で確認する必要があります。

CI で導入

Danger-Swift は CI 側で使うツールなので、最終的に CI に導入しないと意味がありません。

まずは CI での Danger-Swift のインストールですが、これはローカルでのインストールと全く同じです。ただし場合によってはキャッシュに Ruby 製の Danger が残ってる可能性がありますので、Danger-Swift をインストールする前に Ruby 製 Danger をアンインストールするスクリプトを組み込むか、キャッシュを削除して作り直す必要があります(個人的にはキャッシュの作り直しがいいと思います)。

次に Ruby 製 Danger と同じく、GitHub のリポジトリーの場合 DANGER_GITHUB_API_TOKEN の環境変数を設定する必要があります。これは Ruby 製の Danger のものを流用すればいいですし、もしそもそも Ruby 製 Danger を今まで使ったことがなければ、GitHub で Personal access token を新たにつくって CI にそれを設定すればいいです。

プラグインは先ほどにも書いた通り、Marathon 形式で書いていれば Danger-Swift が自動で解決してくれるので、Ruby 製の Danger みたいにわざわざ手動でインストールする必要は特にありません。

そして最後は実行ですが、CI で実行するときのコマンドは danger-swift ci です。

サンプル Dangerfile

当然ながら流石に業務のプロジェクトを見せるわけにはいけませんので、その Dangerfile だけひとまずサンプルとしてあげておきます。プロジェクトに依存している情報は適当に書き換えていますので、自分のニーズに合わせて直せばいいです。Swift コードなので多分読むのはそんなに難しくないはずです。チェックする項目が多いのでクッソ長いですが(時間あったら個人プロジェクトにこれを組み込みますので、終わったらそのプロジェクトもこの記事に入れる予定です)。

import Foundation
import Danger
import DangerXCodeSummary // package: https://github.com/f-meloni/danger-swift-xcodesummary.git
import DangerSwiftCoverage // package: https://github.com/f-meloni/danger-swift-coverage.git

// swiftlint:disable file_length function_body_length

// MARK: - Variables those may change according to each project

let changelogPath = "CHANGELOG.md"
let versionSpecifyingFile = "Sources/Agen/main.swift"
let versionSpecifyingText = "let version = "

// MARK: - A structure to introduce checking result

struct CheckResult {

    let title: String

    private(set) var warningsCount = 0
    private(set) var errorsCount = 0

    enum Result {

        case good
        case acceptable
        case rejected

        var markdownSymbol: String {
            switch self {
            case .good:
                return ":tada:"

            case .acceptable:
                return ":thinking:"

            case .rejected:
                return ":no_good:"
            }
        }

    }

    typealias Message = (content: String, result: Result)
    private var messages: [Message] = []

    private var todos: [String] = []

    init(title: String) {
        self.title = title
    }

    mutating func askReviewer(to taskToDo: String) {

        todos.append(taskToDo)

    }

    mutating func check(_ item: String, execution: () -> Result) {

        let result = execution()
        messages.append((item, result))

        switch result {
        case .good:
            break

        case .acceptable:
            warningsCount += 1

        case .rejected:
            errorsCount += 1
        }

    }

    var markdownTitle: String {

        "### " + title

    }

    var markdownMessage: String {

        let chartHeader = """
            Checking Item | Result
            | ---| --- |

            """
        let chartContent = messages.map {
            "\($0.content) | \($0.result.markdownSymbol)"
        } .joined(separator: "\n")

        return chartHeader + chartContent

    }

    var markdownTodos: String {

        let todoContent = todos.map {
            "- [ ] \($0)"
        }

        return todoContent.joined(separator: "\n")

    }

}

// MARK: - DangerDSL computed properties

extension DangerDSL {

    private var headBranch: String {
        github.pullRequest.head.ref
    }

    private var baseBranch: String {
        github.pullRequest.base.ref
    }

    private var additions: Int {
        github.pullRequest.additions ?? 0
    }

    private var deletions: Int {
        github.pullRequest.deletions ?? 0
    }

    private var diffLinesCount: Int {
        additions + deletions
    }

    private func hasModifiedFile(at filepath: String) -> Bool {
        git.modifiedFiles.contains(where: { $0 == filepath })
    }

    var githubIssue: String? {
        headBranch.substring(of: #"issue/(\d+)"#, options: .regularExpression)
    }

    var hasBootstrapSHFileBeenModified: Bool {
        hasModifiedFile(at: "bootstrap.sh")
    }

    var hasBrewfileBeenModified: Bool {
        hasModifiedFile(at: "Brewfile")
    }

    var hasChangelogBeenModified: Bool {
        hasModifiedFile(at: changelogPath)
    }

    var hasMarketingVersionBeenModified: Bool {
        let diff = Process.runShell("git diff -- \(versionSpecifyingFile)").components(separatedBy: "\n")
        let additions = diff.filter({ $0.hasPrefix("+") })
        return additions.contains(where: { $0.contains(versionSpecifyingText) })
    }

    var isDevelopPR: Bool {
        // Treat PRs merging into develop branch as a develop PR
        baseBranch == "develop"
    }

    var isReleasePR: Bool {
        // Treat PRs merging into master branch as a release PR
        baseBranch == "master"
    }

}

// MARK: - DangerDSL PR content check

extension DangerDSL {

    enum BranchType {

        case master
        case develop
        case feature
        case refactor
        case fix
        case issue
        case version
        case ci

        static func parsed(from branchName: String) -> BranchType? {
            switch branchName {
            case "master":
                return .master

            case "develop":
                return .develop

            case let feature where feature.hasPrefix("feature/"):
                return .feature

            case let refactor where refactor.hasPrefix("refactor/"):
                return .refactor

            case let fix where fix.hasPrefix("fix/"):
                return .fix

            case let issue where issue.hasPrefix("issue/"):
                return .issue

            case let version where version.hasPrefix("version/"):
                return .version

            case let ci where ci.hasPrefix("ci/"):
                return .ci

            case _:
                return nil
            }

        }

    }

    func isHeadBranch(_ branchType: BranchType) -> Bool {

        BranchType.parsed(from: headBranch) == branchType

    }

    func isHeadBranch(anyOf branchTypes: [BranchType]) -> Bool {

        branchTypes.contains(where: { isHeadBranch($0) })

    }

    func isBaseBranch(_ branchType: BranchType) -> Bool {

        BranchType.parsed(from: baseBranch) == branchType

    }

}

// MARK: - DangerDSL modifications content check

extension DangerDSL {

    // Auto PR created by CI
    func checkCIAutoPRModification(into result: inout CheckResult) {

        result.askReviewer(to: "Check if the automatically-created-by-CI content is correct.")
        warn("This PR is created by CI automatically. Please check if the content is correct.")

    }

    func checkDevelopmentModification(into result: inout CheckResult) {

        // It's encouraged to edit changelog in a develop PR
        let doChangelogModificationCheckTitle = "Changelog Modification Check"
        result.check(doChangelogModificationCheckTitle) {
            if hasChangelogBeenModified {
                return .good

            } else {
                warn("This PR doesn't contain any modifications to changelog. Please consider if it's necessary to edit it.")
                return .acceptable
            }
        }

    }

    func checkReleaseModification(into result: inout CheckResult) {

        // It's encouraged to check if there's any issue left before releasing a version
        result.askReviewer(to: "Check open issues")
        warn("This is a release PR. Please check if there's any issue left to be resolved.")

        // It's required to change version number.
        let doVersionModificationCheckTitle = "Version Modification Check"
        result.check(doVersionModificationCheckTitle) {
            if hasMarketingVersionBeenModified {
                warn("This is a release PR. Please check if the version has been correctly modified.")
                return .acceptable

            } else {
                fail("This is a release PR, but it seems there's no version modification, which is requried.")
                return .rejected
            }
        }

        if hasMarketingVersionBeenModified {
            result.askReviewer(to: doVersionModificationCheckTitle)
        }

        // It's strongly encouraged to edit changelog in a release PR.
        let doChangelogModificationCheckTitle = "Changelog Modification Check"
        result.check(doChangelogModificationCheckTitle) {
            if hasChangelogBeenModified {
                warn("This is a release PR. Please check if the changelog has been correctly modified.")
                return .acceptable

            } else {
                fail("This is a release PR, but it seems there's no changelog modification, which is strongly encouraged.")
                return .rejected
            }
        }

        if hasChangelogBeenModified {
            result.askReviewer(to: doChangelogModificationCheckTitle)
        }

    }

}

// MARK: - DangerDSL PR review flow

extension DangerDSL {

    func doDevelopPRCheck() -> CheckResult {

        var result = CheckResult(title: "Develop PR Check")

        // Develop PR should be created from a branch which begins with either `feature/`、`refactor/` 、`fix/`、`issue/`, `version/` or `ci/`.
        let doHeadBranchCheckTitle = "PR Head Branch Check"
        let validBranches: [BranchType] = [
            .feature,
            .refactor,
            .fix,
            .issue,
            .version,
            .ci,
        ]
        result.check(doHeadBranchCheckTitle) {
            if isHeadBranch(anyOf: validBranches) {
                return .good

            } else {
                fail("Please create a develop PR from either a feature, refactor, fix, issue or version branch.")
                return .rejected
            }
        }

        // Develop PR should be created into develop branch
        let doBaseBranchCheckTitle = "PR Base Branch Check"
        result.check(doBaseBranchCheckTitle) {
            if isBaseBranch(.develop) {
                return .good

            } else {
                fail("Please create a develop PR into develop branch.")
                return .rejected
            }
        }

        // Develop PR shouldn't contain any merge commits.
        let doNoMergeCommitsCheckTitle = "Merge Commits Excluded Check"
        result.check(doNoMergeCommitsCheckTitle) {
            if github.commits.allSatisfy({ $0.commit.parents == nil }) {
                return .good

            } else {
                fail("Develop PR should not contain any merge commits. Please consider rebasing if needed.")
                return .rejected

            }
        }

        // The volume of diff should not be over 1,000 lines.
        let doDiffAmountCheckTitle = "Diff Volume Check"
        result.check(doDiffAmountCheckTitle) {
            if diffLinesCount <= 1000 {
                return .good

            } else {
                warn("There's too much diff. Please consider splitting this PR.")
                return .acceptable
            }
        }

        if isHeadBranch(.ci) {
            // If the PR is created from the branch created by CI, do the CI auto PR modification check.
            checkCIAutoPRModification(into: &result)

        } else if isHeadBranch(.version) {
            // If the PR is created from a version branch, do the release modification check.
            checkReleaseModification(into: &result)

        } else {
            // Otherwise, do the develop modification check.
            checkDevelopmentModification(into: &result)
        }

        return result

    }

    func doReleasePRCheck() -> CheckResult {

        var result = CheckResult(title: "Release PR Check")

        // Release PR should be created from develop branch.
        let doHeadBranchCheckTitle = "PR Head Branch Check"
        result.check(doHeadBranchCheckTitle) {
            if isHeadBranch(.develop) {
                return .good

            } else {
                fail("Please create a release PR from develop branch.")
                return .rejected
            }
        }

        // Develop PR should be created into master branch
        let doBaseBranchCheckTitle = "PR Base Branch Check"
        result.check(doBaseBranchCheckTitle) {
            if isBaseBranch(.master) {
                return .good

            } else {
                fail("Please create a release PR into master branch.")
                return .rejected
            }
        }

        // Do the release modification check.
        checkReleaseModification(into: &result)

        return result

    }

    enum RoutineError: Error {
        case failedToFindCheckRoutine
    }

    func checkPR() throws -> CheckResult {

        if isDevelopPR {
            return doDevelopPRCheck()

        } else if isReleasePR {
            return doReleasePRCheck()

        } else {
            throw RoutineError.failedToFindCheckRoutine
        }

    }

}

// MARK: - Other convenient extensions

private extension Git {

    var diffFiles: [File] {
        createdFiles + deletedFiles + modifiedFiles
    }

}

private extension XCodeSummary {

    convenience init(filePath: String, onlyShowSummaryInDiffFiles: Bool) {
        if onlyShowSummaryInDiffFiles {
            let diffFiles = Danger().git.diffFiles
            self.init(filePath: filePath) { [diffFiles] in
                guard let path = $0.file else { return false }
                return diffFiles.contains(path)
            }

        } else {
            self.init(filePath: filePath)
        }
    }

}

private extension String {

    func substring <S: StringProtocol> (of string: S, options: CompareOptions) -> String? {

        guard let range = range(of: string, options: options) else {
            return nil
        }

        return String(self[range])

    }

}

private extension Process {

    static func runShell(_ commands: String...) -> String {

        let task = Process()
        task.launchPath = ProcessInfo().environment["SHELL"]!
        task.environment = ProcessInfo().environment
        task.arguments = ["-c"] + commands

        let pipe = Pipe()
        task.standardOutput = pipe
        task.launch()

        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        let output = String(data: data, encoding: .utf8)!

        return output

    }

}

// MARK: - Check routine
let danger = Danger()

// If there's any modification in bootstrap.sh file, ask the reviewer to check if he needs to update Bitrise workflows.
if danger.hasBrewfileBeenModified {
    markdown("- [ ] Check bitrise workflow")
    warn("There's modification in bootstrap.sh file. Please remember to check if needed to update Bitrise workflow.")
}

// If the head branch refers to a GitHub issue, comment it in the PR page.
if let githubIssue = danger.githubIssue {
    message("Resolve #\(githubIssue)")
}

// SwiftLint format check.
SwiftLint.lint(.modifiedAndCreatedFiles(directory: nil), inline: true)

// Xcode summary warnings check.
XCodeSummary(filePath: "result.json", onlyShowSummaryInDiffFiles: true).report()

// Xcode test coverage check.
Coverage.spmCoverage(minimumCoverage: 60)

// PR routine check.
do {
    let result = try danger.checkPR()
    markdown(result.markdownTitle)

    if !result.markdownMessage.isEmpty {
        markdown(result.markdownMessage)

    }

    if !result.markdownTodos.isEmpty {
        markdown(result.markdownTodos)
    }

    if result.warningsCount == 0 && result.errorsCount == 0 {
        message("Well Done :white_flower:")
    }

} catch {
    fail("Failed to find out the correct check routine. Please check if your PR is created from or into a correct branch.")
}
36
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
20