前言
书接上回,我利用 GitHub Actions 开发了一个 Go 脚本用于为 Markdown 文档添加基于文件夹结构的目录。随即产生了将其单独整合为一个持续集成工具的想法,随即在 Google Keep 写下了这个想法,不过第二天就将其完成并归档了。
目前该工具发布在 Markdown auto catalog · Actions · GitHub Marketplace,使用方法简单可见链接内文档,本文记录创建该工具的过程。
调研 GitHub Action
Creating actions – GitHub Docs
在 GitHub Actions 整个文档中,只有这一章是介绍如何创建并发布自己的 GitHub Action 的,可以创建的 Action 分为三种:Docker、JS、Composite,前两种使用较广,见sdras/awesome-actions: A curated list of awesome actions to use on GitHub,加上我自己找到的一些 Actions,很多人都是使用 Docker、JS,这样他们只用编写很简单的 action.yml 文件,而将大部分操作留给熟悉的 Dockerfile 或 js 文件。
不过这次选择 Composite 方式来完成这个工具。
创建基础文件 action.yml
首先给这个项目创建一个 repo 自然不必多说,然后让这个 repo 能被识别为 GitHub Action 的关键就是在根目录创建一个 action.yml
或 action.yaml
文件,并在这个文件中编写工具的基本运行步骤,Docker/JS 的文档一般是选择镜像或脚本运行即可,Composite 就要详细的步骤。
name: 'Markdown auto catalog'
author: 'minoic'
description: 'Github action automatically update folder-based table of contents in documents.'
inputs:
content-path:
description: 'Path of the documents to be listed in catalog.'
required: true
document-path:
description: 'Path of the catalog document.'
required: true
filter:
description: 'Filename Regex filter'
required: false
default: '\(.*\).md'
runs:
using: 'composite'
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Run
shell: bash
run: go run -v ${{ github.action_path }}/catalog.go -content ${{ inputs.content-path }} -doc ${{ inputs.document-path }} -filter ${{ inputs.filter }}
- name: Commit files
shell: bash
run: |
git config --local user.name "markdown-auto-catalog[bot]"
git config --local user.email "markdown-auto-catalog[bot]@users.noreply.github.com"
git commit -a -m 'Automated catalog update'
- name: Create Pull Request
uses: peter-evans/create-pull-request@v3
with:
title: '[Bot] Automated catalog update'
committer: GitHub <noreply@github.com>
author: markdown-auto-catalog[bot] <markdown-auto-catalog[bot]@users.noreply.github.com>
branch: t/bot/markdown-auto-catalog
branding:
icon: 'list'
color: 'blue'
这里的文档和之前的 catalog.yml
在步骤上几乎完全相同,而又多了不少新的内容。
首先最开头的三行:
- name:必填,工具的名称
- author:可选,作者
- description:必填,工具的描述
其次最后的三行:
- branding:可选,作为发布在 Marketplace 时的图标
- icon: 可以在 https://feathericons.com/ 选择图标名称
- color: 图标的颜色,可选
white
,yellow
,blue
,green
,orange
,red
,purple
, 或gray-dark
然后是 inputs,这部分是将工具分离出来经常需要的步骤,毕竟在具体的项目中直接制作工具就可以因地制宜,而这就像将一个代码块抽象为函数一样,需要为它设置参数。
对于每个 input:
- input_id:必填,即写在外侧的参数名称如
content-path
- description:必填,该参数的描述
- required:可选,该参数是否必填
- default:可选,当留空时的默认值
- deprecationMessage:可选,当你不再推荐使用这个参数时的提示消息
相应的也有 outputs,但因为我没有用到就不介绍了捏。
最后是最主要的 runs,这部分是整个工具的主要步骤,编写起来与上一章的 workflows 略微有一些区别,且可选的参数和功能较少。我这里主要分为签出代码、安装 Go 编译器、运行脚本、提交代码、发起 PR 这几个步骤。由于我选择 Composite 模式的 Action,这里必须要有 using: "composite"
,然后介绍 steps 的参数:
- run:可选,这一步要运行的指令
- shell:可选(有 run 必填),运行指令的环境,有
bash
、pwsh
、python
、sh
、cmd
、powershell
等,详见 这里 - if:可选,判断这个步骤执行与否
- name:可选,步骤名称
- env:可选,环境变量
- working-directory:可选,运行目录
- uses:可选,选择一个 Action 来帮助你完成这个步骤
- with:可选,这一步骤要传入的参数,一般和
uses
组合使用
以及为项目编写详细的 README.md
来介绍使用方法,选择一个合适的 LICENSE
。
编写 action.yml 的注意事项
1. 不要改名
虽然 action.yml、action.yaml 都可以使用,但建立这个文件(无论是哪一种),就不要再改为另一个,否则这个 repo 在发布时会找不到 Marketplace 选项!
在处理这个离奇的问题时,我搜索到 这个Discussion,这个 Bug 在 2019 年就被提出了,居然还没有修复。
2. 脚本文件前面要有地址
- name: Run
shell: bash
run: go run -v ${{ github.action_path }}/catalog.go -content ${{ inputs.content-path }} -doc ${{ inputs.document-path }} -filter ${{ inputs.filter }}
这里虽然 catalog.go
在工具项目的根目录,但也不能只写一个文件名!若要访问工具项目中的文件要有一个 ${{ github.action_path }}
在前面。
通过这张图可以发现目录其实在 /home/runner/work/_actions/minoic/markdown-auto-catalog/v1.0.0/
,如果没写的话会因找不到文件而报错,其它的 context 可以参考 Contexts – GitHub Docs。
3. 不能跳过错误
Composite 模式的 steps 没有在 workflow 中的 continue-on-error: true
选项,因此像我的这个工具在代码没有更改时,Commit 步骤会出错而产生一个红色的❌,非常的丑陋。这时候就要通过文档告诉用户可以在 workflow 中添加跳过的选项。
脚本文件
与上次的脚本相比增加了对参数的解析、用深度优先搜索实现了多级目录支持、支持用正则表达式过滤文件,功能和实现还是非常简单的。
package main
import (
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"regexp"
"strings"
)
var (
DocumentPath string
ContentPath string
Filter string
)
func init() {
flag.StringVar(&DocumentPath, "doc", "", "")
flag.StringVar(&ContentPath, "content", "", "")
flag.StringVar(&Filter, "filter", "", "")
flag.Parse()
}
func dfs(ctl *strings.Builder, folderPath string, depth int) {
fmt.Println(folderPath)
gap := strings.Repeat(" ", depth)
files, _ := ioutil.ReadDir(folderPath)
for _, file := range files {
if file.IsDir() {
ctl.WriteString(fmt.Sprintf("%s- %s\n", gap, file.Name()))
dfs(ctl, path.Join(folderPath, file.Name()), depth+1)
continue
}
if ok, _ := regexp.MatchString(Filter, file.Name()); !ok {
continue
}
ctl.WriteString(fmt.Sprintf("%s- [%s](%s)\n", gap, strings.Trim(file.Name(), "[]"), strings.ReplaceAll(path.Join(folderPath, file.Name()), " ", "%20")))
}
}
func main() {
readme, err := os.OpenFile(DocumentPath, os.O_RDWR, os.ModePerm)
if err != nil {
panic(err)
}
readmeB, err := io.ReadAll(readme)
if err != nil {
panic(err)
}
readmeS := string(readmeB)
flag := "<!-- catalog -->"
splits := strings.Split(readmeS, flag)
ctl := strings.Builder{}
ctl.WriteString("\n\n")
dfs(&ctl, ContentPath, 0)
readmeS = splits[0] + flag + ctl.String() + "\n" + flag + splits[2]
err = readme.Truncate(0)
if err != nil {
panic(err)
}
_, err = readme.Seek(0, 0)
if err != nil {
panic(err)
}
_, err = readme.WriteString(readmeS)
if err != nil {
panic(err)
}
}
发布
在这之前,GitHub 可能要求你设置二次登录验证来确保安全。
要发布到 Marketplace 只需在 Release 页面点击 “Draft new release” 并选中 “Publish this Action to the GitHub Marketplace” 选项,当所有条目检测通过时即可发布。
应用
minoic/stackoverflow-top-cpp: stackoverflow 上对 C/C++ 问题的整理、总结和翻译。
name: markdown-auto-catalog
on:
push:
branches: [ "master" ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: minoic/markdown-auto-catalog@v1.0.0
with:
content-path: 'question'
document-path: 'README.md'
filter: '\(.*\).md'
continue-on-error: true