Git 项目的 CI 流水线-阿里云开发者社区

开发者社区> 开发与运维> 正文
登录阅读全文

Git 项目的 CI 流水线

简介: 持续集成是软件项目成功的关键。通过持续集成自动化地执行代码静态检查、构建、测试,能够及时发现代码变更引入的问题,有效保障项目质量。Git 作为一个成功的开源项目,自然少不了持续集成。Git 的持续集成Git 项目使用邮件列表进行代码评审,即使后来 GitHub 在开源社区如日中天,Git 项目也仅仅把 GitHub 作为多个代码托管源之一,视 GitHub 为一个可有可无的存在。改变来自于 CI(

持续集成是软件项目成功的关键。通过持续集成自动化地执行代码静态检查、构建、测试,能够及时发现代码变更引入的问题,有效保障项目质量。Git 作为一个成功的开源项目,自然少不了持续集成。

Git 的持续集成

Git 项目使用邮件列表进行代码评审,即使后来 GitHub 在开源社区如日中天,Git 项目也仅仅把 GitHub 作为多个代码托管源之一,视 GitHub 为一个可有可无的存在。改变来自于 CI(持续集成)。

Travis CI

2016年1月,Git 2.7.0 版本引入了与 Travis CI 的集成,为 GitHub 上托管的 Git 项目整合了持续集成功能,那个我们熟悉的小小的对号(✓)出现了。

Travis CI 是业界首个为开源项目提供免费的持续构建服务的提供商。Travis CI 要求在项目代码仓库的根目录中添加一个 travis.yml 文件来定义 CI 的流程,如今这已经成为代码整合 CI 的共识——CI as Code。此外还要在 Travis CI 上创建账号和项目,并打通和 GitHub 代码仓库的关联。

不过 Travis CI 在2019年被收购后针对开源项目的构建策略出现了不确定性变化,Git项目的CI构建遂转向其他平台。比如使用 Cirrus CI 承担 Git 在 FreeBSD 平台的持续集成,使用 Azure Pipelines/DevOps 替换 Travis CI。

Azure Pipelines

2019年2月,在 Git 2.21.0 版本引入了与微软 Azure Pipelines 的集成,用以替换 Travis CI。Azure Pipelines 提供了对 Windows 环境下构建更好的支持。Git 项目也首次在 README 文件中嵌入构建勋章(build badge)。

 

但无论是 Travis CI、Circus CI 还是 Azure Pipelines,都不是 GitHub 原生的 CI 解决方案,存在如下问题:

  • 代码存储在 GitHub,而运行 CI/CD 的资源位于另外一个网站,要在另外的网站上注册账号、创建项目、关联GitHub 仓库,操作繁琐。
  • 派生项目需要单独打通 CI/CD,使得派生项目启用 CI/CD 的门槛高。
  • 需要在代码仓库安装认证相关的 GitHub APP,以实现 CI/CD 和代码仓库之间的互操作。
  • 在 GitHub 上查看流水线执行的详细日志输出,需要跳转到另外的平台。

解决上述问题,需要原生的 CI 解决方案,就是 GitHub Actions。

GitHub Actions

GitHub 于 2018 年被微软收购,同一年 GitHub 推出了 GitHub Actions 服务。GitHub Actions 是 GitHub 内置的自动化流水线服务,实现了原生的 CI as Code,免去了在多个平台上设置、跳转的繁琐。

派生仓库能够直接获得上游仓库的流水线能力,且派生仓库和上游仓库的流水线各自使用各自的流水线资源,各自付费(如果有的话)。派生仓库只需要在 Actions 界面中点击一下确认按钮即可开启流水线能力,如下图所示。

为创建一条流水线(workflow),要在仓库的 .github/workflows/ 子目录下创建名为 <workflow>.yml 文件。以 Git 项目为例,由 main.yml 定义的主流水线包含了20多项任务(jobs),各项任务通过依赖关系形成三个层级。存在依赖关系的任务串行,一个层级内的多项任务并行。

每一个任务(job)又由多个串行的步骤(steps)组成。例如名为 regular (linux-clang, clang, ubuntu-latest) 的任务包括串行的多个步骤,如下图中的“set up job”、“Run actions/checkout@v2”等步骤。

2020年5月 Git 项目使用 GitHub Actions 提供的更好的内建 CI 能力替换了基于 Azure Pipelines 的 CI。

创建新的流水线

下面以 Git 的本地化流水线为例,介绍如何从头创建一条 CI 流水线。

2021年我为 Git 项目贡献了本地化 CI 流水线,这是因为我作为 Git 本地化协调者,需要一个自动化流程代替手工检查以提升效率。早在 2012年就开发了一个名为 git-po-helper 的助手程序,对本地化贡献进行检查,包括:检查贡献者的改动是否仅限于 po/ 目录下文件、提交说明是否符合规范、提交日期是否超前、修改的 *.po 文件格式是否正确、在翻译中是否存在笔误等。为了能将 git-po-helper 助手程序集成到 Git 本地化仓库(git-l10n/git-po)的 pull request 自动评审中,向 Git 项目贡献了新的 CI 流水线文件: .github/workflows/l10n.yml

流水线名称和触发事件

首先在 YAML 文件中定义流水线名称和设置流水线的触发事件,如下:

name: git-l10n
on: [push, pull_request_target]

其中 push 事件好理解,即 git push 推送操作可以触发此流水线。那么这里为什么使用 pull_request_target 事件而不是 pull request 事件?

  • pull request 事件执行的是合并提交中的流水线代码,用户可能在 pull request 中包含恶意流水线代码,引发安全风险。出于安全性考虑,GitHub 收紧了响应 pull_request 事件的流水线的授权,访问代码仓库的 TOKEN 设置为只读。这对于大多数场景可以满足需要,但是需要以读写权限调用 GitHub API 的流水线会失败。
  • 在 Git 本地化流水线中,要将运行结果作为一条评论消息添加到 pull request 中,这需要具有写权限的 TOKEN,pull_request 事件不满足需要。
  • GitHub 提供了一条新的触发事件 pull_request_target。与 pull_request 事件的区别在于 pull_request_target 事件触发的流水线运行在 pull request 的基准提交上,规避了 pull_request 事件触发流水线的安全风险,因此 GitHub 为 pull_request_target 事件提供了具有读写权限的 TOKEN。

定义任务

每一条流水线由若干个任务组成。在 Git 本地化流水线的第一个版本中定义了两个任务,ci-config 和 git-po-helper。其中 git-po-helper 任务依赖 ci-config 任务,即 ci-config 任务为前置任务,如下图所示。

Git 本地化流水线仅需运行在 Git 本地化仓库(git-l10n/git-po)及其派生仓库上。对于绝大多数的功能开发,这条流水线是不必要的,而且会产生误报。因此需要有一个机制来默认关闭 Git 本地化流水线。在第一版的本地化流水线代码中,我参考了 Git 主干流水线,为 Git 本地化流水线设计了两个任务。

  • 第一个任务名为 ci-config,用于检查仓库中是否包含名为 ci-config 的分支,以及该分支中是否存在 ci/config/allow-l10n 文件,如果存在该文件,返回“yes”,否则返回“no”。
  • 第二个任务名为 git-po-helper,通过 needs: 设置任务依赖,即依赖于 ci-config 任务。通过 if: 设置启动条件,即只有 ci-config 任务执行结果为“yes”时,才执行本任务。

相关代码如下所示:

jobs:
  ci-config:
    runs-on: ubuntu-latest
    outputs:
      enabled: ${{ steps.check-l10n.outputs.enabled }}
    steps:
      ...
  git-po-helper:
    needs: ci-config
    if: needs.ci-config.outputs.enabled == 'yes'
    runs-on: ubuntu-latest
    steps:
      ...

每一个任务都是独立运行的,有独立的运行时环境。在上面的示例中使用 runs-on: ubuntu-latest 定义以运行着 Ubuntu 最新版本的虚拟机作为运行时环境。

每一个任务还要包含由 steps: 定义的若干执行步骤。

定义执行步骤

一个任务包含一个或多个执行步骤。每个执行步骤可以是一条或一组命令(默认使用 shell 脚本),也可以是一个封装好的 GitHub Actions 组件。

单命令行

下面的示例是 git-po-helper 任务中的一个简单的执行步骤,名为“Install git-po-helper”,即安装 git-po-helper 工具。安装命令为一条 shell 语句,直接用 run: <command> 语法进行声明。

jobs:
  git-po-helper:
    ... ...
    steps:
      ... ...
      - name: Install git-po-helper
        run: go install github.com/git-l10n/git-po-helper@main

多命令行

有的步骤包含多条 shell 命令,可以在 run: 后面接一个管道符号,多条命令以向右缩进方式列在下方。

      - name: Install other dependencies
        run: |
          sudo apt-get update -q
          sudo apt-get install -q -y gettext

调用 Github Actions 组件

GitHub Actions 组件,简称 GitHub Actions,是将一组命令封装成可以重复调用的组件,这个组件可以有输入参数,和输出结果。一个组件用作流水线的一个执行步骤。

例如下面的执行步骤使用了名为 actions/setup-go@v2 的 GitHub Actions 组件,用于在运行时环境中安装 Go 语言环境。一个输入参数 go-version: '>=1.16' 指定 Go 语言的版本。

      - uses: actions/setup-go@v2
        with:
          go-version: '>=1.16'

每个组件都指向 GitHub 上的一个开源仓库,上面的 actions/setup-go 组件可以通过 https://github.com/actions/setup-go查看源码和使用帮助。

通过输出变量在步骤间传递单行文本

一个执行步骤可以通过输出(outputs)变量在不同的步骤之间传递信息。只要在一个执行步骤中输出文本 ::set-output name=<name>::<value> 即可生成可被其他步骤访问的 <name> 变量。

      - name: Setup base and head objects
        id: setup-tips
        run: |
          if test "${{ github.event_name }}" = "pull_request_target"
          then
            base=${{ github.event.pull_request.base.sha }}
            head=${{ github.event.pull_request.head.sha }}
          else
            base=${{ github.event.before }}
            head=${{ github.event.after }}
          fi
          echo "::set-output name=base::$base"
          echo "::set-output name=head::$head"

上面执行步骤中生成的两个输出变量,可以在其他步骤中引用。例如下面的部分克隆操作(Run partial clone)就使用了 ${{ steps.setup-tips.outputs.base }} 和 ${{ steps.setup-tips.outputs.head }} 变量来引用相关数据。

    steps:
      ... ...
      - name: Run partial clone
        run: |
          git -c init.defaultBranch=master init --bare .
          git remote add \
            --mirror=fetch \
            origin \
            https://github.com/${{ github.repository }}
          # Fetch tips that may be unreachable from github.ref:
          # - For a forced push, "$base" may be unreachable.
          # - For a "pull_request_target" event, "$head" may be unreachable.
          args=
          for commit in \
            ${{ steps.setup-tips.outputs.base }} \
            ${{ steps.setup-tips.outputs.head }}
          do
            case $commit in
            *[^0]*)
              args="$args $commit"
              ;;
            *)
              # Should not fetch ZERO-OID.
              ;;
            esac
          done
          git -c protocol.version=2 fetch \
            --progress \
            --no-tags \
            --no-write-fetch-head \
            --filter=blob:none \
            origin \
            ${{ github.ref }} \
            $args

使用输出变量只能在步骤间传递单行文本,如果要传递多行文本,需要对换行符进行转义。一个传递多行文本更简单的办法是使用环境变量。

通过环境变量在步骤间传递多行文本

环境变量也可以用于在步骤间传递信息。例如设置一个名为 {name} 值为 {value} 的环境变量:

echo "{name}={value}" >> $GITHUB_ENV

环境变量还支持多行文本且无需对换行符进行转义。例如向 $GITHUB_ENV 输入如下内容创建包含多行文本的名为 {name} 的环境变量,该环境变量的取值 {value} 可包含多行文本。

{name}<<{delimiter}
{value}
{delimiter}

下面是 Git 本地化流水线的一个步骤:

  • 运行 git-po-helper,将命令行输出结果保存到 git-po-helper.out 文本文件中。
  • 如果输出的文本文件中包含错误信息或警告信息,则设置名为 COMMENT_BODY 的环境变量。
      - name: Run git-po-helper
        id: check-commits
        run: |
          exit_code=0
          git-po-helper check-commits \
            --github-action-event="${{ github.event_name }}" -- \
            ${{ steps.setup-tips.outputs.base }}..${{ steps.setup-tips.outputs.head }} \
            >git-po-helper.out 2>&1 || exit_code=$?
          if test $exit_code -ne 0 || grep -q WARNING git-po-helper.out
          then
            # Remove ANSI colors which are proper for console logs but not
            # proper for PR comment.
            echo "COMMENT_BODY<<EOF" >>$GITHUB_ENV
            perl -pe 's/\e\[[0-9;]*m//g; s/\bEOF$//g' git-po-helper.out >>$GITHUB_ENV
            echo "EOF" >>$GITHUB_ENV
          fi
          cat git-po-helper.out
          exit $exit_code

生成的取值包含多行文本的环境变量 COMMENT_BODY 可以在后续步骤中使用如下语法引用:

 ${{ env.COMMENT_BODY }}

在 pull request 中添加评论

通常 CI 流水线向用户展示一个非零即一的执行结果就可以了。如果用户发现流水线运行失败,进入“Actions”菜单,查看流水线的执行结果就可以看到详细的运行日志。但是有的场景这还不够。

对于 Git 本地化流水线,git-po-helper 在检查翻译中的笔误时,可能存在误报,因此以警告信息的形式显示修改建议,而程序正常退出,不视为错误。这种场景下,用户不能通过 CI 流水线成功与否的返回值获知潜在的问题。将 git-po-helper 发现的错误和警告信息以评论的方式添加到 pull request 是一个好的解决方案。

下面的执行步骤调用了名为 mshick/add-pr-comment@v1 的 GitHub Actions 组件,在 pull request 中追加一条评论。

      - name: Create comment in pull request for report
        uses: mshick/add-pr-comment@v1
        if: >-
          always() &&
          github.event_name == 'pull_request_target' &&
          env.COMMENT_BODY != ''
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          repo-token-user-login: 'github-actions[bot]'
          message: >
            ${{ steps.check-commits.outcome == 'failure' && 'Errors and warnings' || 'Warnings' }}
            found by [git-po-helper](https://github.com/git-l10n/git-po-helper#readme) in workflow
            [#${{ github.run_number }}](${{ env.GITHUB_SERVER_URL }}/${{ github.repository }}/actions/runs/${{ github.run_id }}):
            ```
            ${{ env.COMMENT_BODY }}
            ```

这个执行步骤紧接着“Run git-po-helper”步骤,如果“Run git-po-helper”步骤执行出错,默认后续执行步骤都不再执行,整个流水线任务显示为执行失败。为了在前面的步骤执行失败时仍然能够在 pull request 中创建评论,在 if: 指令中调用了 always() 方法。

在 mshick/add-pr-comment@v1 组件的参数中,使用 ${{ env.COMMENT_BODY }} 引用上一个步骤设置的包含多行输出的环境变量,实现在追加的评论中嵌入错误及警告日志。

创建新的 GitHub Actions 组件

开发一个 CI 流水线,主要的编码工作是在执行步骤(steps)中,支持用 shell、python等脚本语言。如果需要调用 GitHub API 实现更为复杂的功能,或者实现代码复用,可以开发 GitHub Actions 组件。

在实现 Git 本地化流水线过程中,Johannes 和 Peff 都指出使用 ci-config 任务判断是否需要执行本地化流水线太重了,可以有更简洁的方案,比如判断仓库名称是否为 git-po

我在尝试简化 ci-config任务过程中,搜索 GitHub Actions 市场,没有找到适合的 GitHub Actions 组件,于是决定自己开发一个。这个 GitHub Actions 组件要能够:无需仓库克隆,直接调用 GitHub API 判断仓库是否存在 ci-config 分支且分支中存在 ci/config/allow-l10n 文件。

创建开源项目

开发一个 GitHub Actions 组件,首先需要在 GitHub 上创建一个开源仓库来托管 GitHub Actions 组件。仓库地址:https://github.com/jiangxin/file-exists-action

一个 GitHub Actions 组件要在仓库中包含 action.yml 文件,用以描述相关执行逻辑。GitHub 为 GitHub Actions 组件提供了三种编程模式:Docker容器、JavaScript、组合模式(Composite Actions)。其中 Docker 容器模式最慢。组合模式采用 GitHub Actions 流水线相似的语法,相当于将多个步骤组合为一个操作,编写更简单。本示例中的 GitHub Actions 组件即使用组合模式编写。

输入参数

首先在 actions.yml 中定义输入参数,如下:

inputs:
  repository:
    description: Repository name with owner. For example, jiangxin/file-exists-action
    required: false
    default: ${{ github.repository }}
  ref:
    description: The branch, tag or SHA to search the file path
    required: false
    default: ""
  path:
    description: File path to find in the repository
    required: true
  type:
    description: Expected type of file in the repository
    required: false
    default: file
  token:
    description: Personal access token (PAT) used to access GitHub API
    required: false
    default: ${{ github.token }}

其中 repository(仓库)、ref(引用名称)、type(类型)、token(秘钥)都有缺省值,只有 path(路径)是必选参数。

输出参数

本组件包含一个输出参数 exists,如果请求的分支和路径在给定的仓库中存在,返回 true,否则返回 false。

输出参数 exists 的取值来自于 ID 为 parse-api 的步骤的输出变量 exists

outputs:
  exists:
    description: Return true or false for the existence of the file in repository
    value: ${{ steps.parse-api.outputs.exists }}

实现

实现分作两个步骤,第一个步骤使用 curl 访问 GitHub API 查询分支内给定路径的文件,并将返回结果(JSON 格式)输出到变量 response 中:

runs:
  using: "composite"
  steps:
    - name: Call GitHub API
      id: call-api
      shell: bash
      run: |
        url="$GITHUB_API_URL/repos/${{ inputs.repository }}/contents/${{ inputs.path }}"
        if test "${{ inputs.ref }}" != ""
        then
          url="$url?ref=${{ inputs.ref }}"
        fi
        resp=$(curl \
                --header "Content-Type: application/json" \
                --header "Accept: application/vnd.github.v3+json" \
                --header "Authorization: Bearer ${{ inputs.token }}" \
                "$url")
        # the following lines are only required for multi line json
        resp="${resp//'%'/'%25'}"
        resp="${resp//$'\n'/'%0A'}"
        resp="${resp//$'\r'/'%0D'}"
        # end of optional handling for multi line json
        echo "::set-output name=response::$resp"

第二个步骤是调用 GitHub Actions 内部方法 fromJSON() 来解析上一步的输出结果,判断路径是否存在,并将结果输出到名为 exists 的变量中。

    - name: Parse API result
      id: parse-api
      shell: bash
      run: |
        exists=false
        type="${{ fromJSON(steps.call-api.outputs.response)['type'] }}"
        echo "::debug::URL: $url"
        echo "::debug::Type: $type"
        if test "$type" = "${{ inputs.type }}"
        then
          exists=true
        fi
        echo "::set-output name=exists::$exists"

调用自建 GitHub Actions 组件

可以在流水线中调用自建的 GitHub Actions 组件。在下面的流水线定义中,包含了两个步骤,“check-repo-name”和“check-file-exists”,分别验证仓库名称以及仓库中是否存在特定分支、路径。在“check-file-exists”步骤中调用了自建的 jiangxin/file-exists-action@v1 组件。

jobs:
  l10n-config:
    runs-on: ubuntu-latest
    outputs:
      enabled: ${{ steps.check-repo-name.outputs.matched == 'yes' || steps.check-file-exists.outputs.exists }}
    steps:
      - id: check-repo-name
        name: Check repo name
        run: |
          matched=false
          if ${{ endsWith(github.repository, '/git-po') }}
          then
            matched=true
          fi
          echo "::set-output name=matched::$matched"
      - id: check-file-exists
        name: Check file in branch
        if: steps.check-repo-name.outputs.matched != 'yes'
        uses: jiangxin/file-exists-action@v1
        with:
          ref: ci-config
          path: ci/config/allow-l10n

GitHub Actions 已经成为 GitHub 上项目 CI/CD 的首选,启用 GitHub Actions 为你的项目保驾护航。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章
最新文章
相关文章