Shell 和 PowerShell 脚本、任务入口(just、Task、Make)、YAML/JSON/TOML 配置、.env 文件、Dockerfile、SQL 文件和 CI 配置文件怎么写、用什么工具检查。
可执行脚本首行必须写 shebang,且与实际语法一致;用 #!/bin/sh 的脚本里不写 Bash 专有语法
怎么检查:ShellCheck 对缺少 shebang 报 SC2148;按 shebang 判定方言后,对 sh 脚本里的 Bash 语法报 SC3xxx 系列警告
为什么、来源
为什么:解释器写错时脚本在开发机上能跑、换到只有 dash 或 BusyBox 的环境里就出错,而且错误往往不在第一行就暴露。
- Bash 脚本用
#!/usr/bin/env bash;需要在最小镜像或系统初始化阶段运行的才写 POSIX sh。
- 要跨平台运行的脚本,写明支持哪些环境并逐个实测:macOS 自带的 Bash 版本很旧、BusyBox ash、Git Bash、WSL 都不能当成同一个环境。
- zsh 适合交互使用,不作为自动化脚本的默认解释器。
仓库里所有 Shell 脚本(包括没有 .sh 后缀的可执行文件)在 CI 上跑 ShellCheck,有告警即失败
怎么检查:用 shfmt -f . 按后缀和 shebang 列出全部 Shell 文件,交给 shellcheck 检查
为什么、来源
为什么:只查 scripts/*.sh 会漏掉 bin/ 下没有后缀的脚本,而那往往是最常被执行的。
个别告警确实不适用时,在该行上方用 # shellcheck disable=SCxxxx 指明规则并写理由(见 COD-039),不整文件关闭。
Shell 脚本用 shfmt 统一格式,CI 用 shfmt -d 检查;缩进等选项写在 .editorconfig 里
怎么检查:CI 运行 shfmt -d .,有差异即失败
为什么、来源
为什么:格式交给工具,评审才不用讨论空格;shfmt 会读取 .editorconfig,编辑器和 CI 用同一份设置。
格式化只管排版,不能替代 ShellCheck 的正确性检查。
变量展开和命令替换一律加双引号;传多个参数用 "$@" 或数组,不拼成一个字符串
怎么检查:ShellCheck 的 SC2086(未加引号的展开)、SC2068(未加引号的 $@)等规则
为什么、来源
为什么:不加引号时,含空格、换行或通配符的值会被拆开或展开,轻则出错,重则删错文件。
只有确实要做通配符展开时才不加引号,并写注释说明。遍历文件不用 for f in $(ls),用 find -print0 配合 while read -r -d '',或直接用通配符。
Bash 脚本开头默认写 set -euo pipefail;预期可能失败的命令用 if 或显式检查退出码,不把 -e 当异常处理
怎么检查:脚本扫描检查开头的 set 行;评审预期失败的命令是否显式处理
为什么、来源
为什么:-e 在 if 条件、&&/|| 链、部分函数调用里不会触发退出,指望它兜底会漏掉错误。
pipefail 让管道里任何一段失败都算失败;但 grep -q 这类提前退出的命令会让上游收到 SIGPIPE,此时要显式处理,而不是全局关掉。
- 目标 sh 不一定支持
pipefail,写 POSIX sh 时要在目标 shell 上实测,或逐步检查返回码。
不可信输入不得进入 eval、bash -c、sh -c 或拼接出的命令字符串;命令和参数分开传
怎么检查:扫描 eval、-c 调用作为候选,评审其参数来源
为什么、来源
为什么:拼进命令字符串的外部内容会被当成代码执行,是命令注入的直接入口。
临时文件和目录用 mktemp 创建并用 trap 清理;rm -rf 用到的路径变量先确认非空且在受控目录内
怎么检查:ShellCheck 的 SC2115 提示用 ${var:?} 防止变量为空时删到根目录;评审删除路径的来源
为什么、来源
为什么:固定文件名的临时文件会被并发运行互相覆盖;变量为空时 rm -rf "$dir/"* 会删到意想不到的地方。
tmp_dir=$(mktemp -d)
trap 'rm -rf -- "$tmp_dir"' EXIT
错误信息写到标准错误,失败返回非零退出码;会改外部状态的脚本提供预演(dry-run)或先预检再执行
怎么检查:评审时看错误输出位置、退出码和危险操作前的预检
为什么、来源
为什么:调用方和 CI 靠退出码判断成败,靠标准输出拿结果;两者混在一起就没法组合使用。
脚本出现复杂分支、状态机、并发、重试或要读写 JSON/YAML 时,改用项目主语言写
怎么检查:评审新增或大幅修改的脚本时判断是否还适合用 Shell
参考值:Google Shell 风格指南建议超过约 100 行或控制流复杂时重新考虑;这是重新评估的信号,不是硬上限。
为什么、来源
为什么:Shell 没有可靠的数据结构和错误处理,逻辑一复杂就难测、难改。
只是读取或小改 JSON/YAML 时用 jq、yq 这类解析工具,不用 sed、grep 按文本改结构化数据。
PowerShell 脚本在 CI 上跑 PSScriptAnalyzer,不用 ShellCheck 查 .ps1
怎么检查:CI 运行 Invoke-ScriptAnalyzer -Recurse,有错误级问题即失败
为什么、来源
为什么:PowerShell 是另一门语言,ShellCheck 读不懂它。
脚本开头写 Set-StrictMode -Version Latest 和 $ErrorActionPreference = 'Stop',让未定义变量和非终止错误也能暴露出来。测试用 Pester。
仓库根目录提供一个任务入口(justfile、Taskfile.yml、Makefile 或 package.json 脚本),能列出全部任务及说明
怎么检查:检查根目录存在任务入口,just --list、task --list 等能列出任务;贡献说明里写明所需工具
为什么、来源
为什么:新人和 Agent 不用翻文档猜命令;CI 也能调用同一套命令(见 03)。
适用的任务统一用这些名字:help、dev、test、lint、format、check、build、clean;不适用的不建空壳
怎么检查:脚本列出入口里的任务名,与约定名单比对
为什么、来源
为什么:名字统一后,在任何仓库里都知道该敲什么,Agent 也不用逐个仓库摸索。
lint:静态检查,不改文件。
format:格式化,会改文件。
check:lint、类型检查、快速测试等只读检查的合集,CI 和本地都跑它。
clean:只删本项目生成的文件。
check、lint、test 任务不得修改工作区里的文件
怎么检查:CI 跑完 check 后执行 git status --porcelain,输出非空即失败
为什么、来源
为什么:检查任务顺手改了文件,CI 就会在改过的代码上判通过,而提交上去的却是没改的版本。
任务失败必须返回非零退出码;不用 || true、Make 的 - 前缀、Task 的 ignore_error、CI 的 allow_failure 吞掉检查失败
怎么检查:扫描任务文件和 CI 配置里的这些写法作为候选,逐个确认有理由且不是必过检查
为什么、来源
为什么:失败被吞掉后流水线一片绿,问题要到线上才暴露。
确实允许失败的步骤(如可选的上报),在旁边写注释说明理由。
部署、发布、重置数据这类有外部副作用的任务单独命名(如 deploy-staging、reset-db),不挂在默认任务、check 或 test 之下
怎么检查:评审任务入口时检查默认任务和检查类任务的依赖链
为什么、来源
为什么:敲一个看起来无害的命令却连带部署或清库,是最难追溯的事故之一。
Makefile 里不对应文件的目标写进 .PHONY;配方用到 Bash 语法时显式设置 SHELL 和 .SHELLFLAGS
怎么检查:评审 Makefile;检查非文件目标是否在 .PHONY 中
为什么、来源
为什么:不声明 .PHONY 时,仓库里恰好有同名文件或目录,目标就会被跳过;Make 默认用 /bin/sh,Bash 语法会在部分系统上失败。
例如 SHELL := bash 和 .SHELLFLAGS := -euo pipefail -c。配方行必须用 Tab 缩进,在 .editorconfig 里给 Makefile 设 indent_style = tab。
按消费者选配置格式:机器之间交换用 JSON,已有生态约定的(CI、容器编排)用 YAML,人工编辑的应用配置可用 TOML;不为统一而强行转换
怎么检查:评审新增配置文件时确认格式是消费者原生支持的
为什么、来源
为什么:格式由读它的程序决定;强行统一成一种格式,往往要多一层转换,还会丢掉注释或类型。
环境变量只放部署时会变的简单值和密钥引用,不把整段 JSON 塞进一个变量。
机器读取、结构固定的配置文件配一份入库的 JSON Schema,CI 用 check-jsonschema 校验;校验时使用本地 Schema,不联网拉取
怎么检查:CI 运行 check-jsonschema --schemafile schemas/xxx.json <配置文件>
为什么、来源
为什么:只检查「是合法的 YAML」挡不住拼错的键和错误的类型,这类错误往往到启动或运行时才暴露。
- Schema 用 2020-12 版,写明
required、类型、枚举和取值范围;未知字段默认拒绝(additionalProperties: false),要扩展时再放开。
- 校验分四步:能解析 → 符合 Schema → 业务语义正确(引用存在、取值组合合理)→ 不含明文密钥。
- 配置结构有破坏性变化时,Schema 带版本号,并写清迁移办法。
- 断网环境要把 Schema 及其引用的元 Schema 放在本地。
YAML 和 JSON 配置不得出现重复键
怎么检查:YAML 用 yamllint 的 key-duplicates 规则(默认开启);JSON 用能检出重复键的工具,如 pre-commit-hooks 的 check-json 或 Biome 的 noDuplicateObjectKeys
为什么、来源
为什么:YAML 规范要求键唯一,JSON 规范说重复键的处理结果不可预测;不同解析器有的取前一个、有的取后一个,同一份配置在两个程序里含义不同。
YAML 里会被自动转换类型的值加引号,如 yes/no/on/off、像版本号的 1.10、带前导零的数字、12:30 这类时间
怎么检查:yamllint 的 truthy 和 octal-values 规则调成 error 级别;像版本号的浮点值靠评审和反序列化测试
为什么、来源
为什么:不同 YAML 解析器对这些值的处理不同:no 可能变成 false,1.10 可能变成 1.1,结果是配置被悄悄改了。
GitHub Actions 的 on: 是键不是值,给 truthy 规则设 check-keys: false 避免误报。
YAML 文件在 CI 上跑 yamllint,配置文件入库;与格式器冲突的排版规则关掉,只留格式器管不到的规则
怎么检查:CI 运行 yamllint -s .(-s 让告警也失败)
为什么、来源
为什么:格式器只管排版,重复键、隐式类型、语法错误要靠 yamllint 发现。
同时用 Prettier 时,yamllint 的缩进、行宽等排版规则交给 Prettier,避免两个工具互相打架。
JSON 和 YAML 用同一个格式器(默认 Prettier),CI 用检查模式验证
怎么检查:CI 运行 prettier --check "**/*.{json,yml,yaml}"
为什么、来源
为什么:一种文件只用一个格式器,不同人、不同编辑器保存后才不会产生无意义的改动。
- TypeScript 项目已用 Biome 格式化 JSON 的,JSON 交给 Biome,Prettier 只管 YAML,不要两个都管 JSON(见 10)。
- 带注释的 JSONC 只用于明确支持它的文件(如
tsconfig.json、编辑器设置),不把它当标准 JSON 交给其他解析器。
TOML 文件用一个格式化和检查工具(默认 Tombi),CI 用检查模式验证
怎么检查:CI 运行该工具的检查模式(如 tombi format --check 和 tombi lint)
为什么、来源
为什么:TOML 的点号键、表数组、日期时间类型容易写错,靠肉眼发现不了。
已经在用 Taplo 的项目可以继续用,但它已转为个人维护、发布很少,新项目不再选它。工具通过不代表目标程序的 TOML 解析器行为一致,关键配置要用目标程序实际加载一次。
.env 等本地环境文件写进 .gitignore,不入库;入库的 .env.example 只列变量名、说明和非敏感默认值
怎么检查:CI 检查版本库里没有除 .env.example 以外的 .env、.env.* 文件,并对仓库跑密钥扫描(工具见 25)
为什么、来源
为什么:.env 里通常有真实密码和令牌,一旦提交就进了历史,删文件也删不掉。
代码新增或删除了环境变量,同一个改动里同步更新 .env.example;可以用脚本比对代码读取的变量名和示例文件。
环境变量名只用大写字母、数字和下划线,并带应用或服务前缀,如 ORDER_SVC_DB_URL
怎么检查:脚本检查 .env.example 和配置定义里的变量名格式
为什么、来源
为什么:POSIX 约定工具使用的环境变量名由大写字母、数字和下划线组成;加前缀避免和系统或其他程序的变量撞名。
.env 文件只用于本地开发;生产环境由部署平台或密钥服务注入,不把 .env 打进镜像
怎么检查:评审部署配置和 Dockerfile,确认没有复制 .env
为什么、来源
为什么:配置随部署变化、和代码分开存放;打进镜像的 .env 会随镜像四处分发。
- .env 没有统一的语法标准,各加载库对引号、插值、多行的处理不同,只写简单的
KEY=value。
- 脚本里不要
source 来路不明的 .env:它会被当成 Shell 代码执行。
Dockerfile 在 CI 上跑 hadolint 和 docker build --check,并实际构建一次
怎么检查:CI 运行 hadolint Dockerfile 和 docker build --check .,再执行真实构建
为什么、来源
为什么:lint 能发现写法问题,但只有真的构建才能证明它能用;两者缺一不可。
- hadolint 会对 RUN 里的 Shell 片段调用 ShellCheck。
- RUN 里用了管道时,先设
SHELL ["/bin/bash", "-o", "pipefail", "-c"](hadolint DL4006),否则管道前段失败会被忽略。
.hadolint.yaml 入库,忽略某条规则要写理由。
正式发布的镜像,FROM 不用 latest 或不带标签的基础镜像;用标签加 digest 固定,由依赖更新机器人提 PR 升级
怎么检查:hadolint 的 DL3006(未带标签)、DL3007(用了 latest);另用脚本检查 FROM 行是否带 @sha256:
为什么、来源
为什么:标签会被重新指向别的内容,不固定就说不清某次构建用的是哪个基础镜像。
固定 digest 不等于构建可复现:包下载、时间戳、编译器、架构都可能让两次构建结果不同。基础镜像怎么选见 26。
最终镜像以非 root 用户运行;确实需要 root 的,写明原因和运行时的限制措施
怎么检查:hadolint 的 DL3002(最后的 USER 是 root);构建后运行 id 确认
为什么、来源
为什么:容器里的进程一旦被攻破,以 root 运行会让攻击者更容易逃逸或破坏宿主机挂载的数据。
构建时需要的凭据用 BuildKit 的 secret 或 ssh 挂载传入,不用 ARG、ENV 或 COPY
怎么检查:docker build --check 的 SecretsUsedInArgOrEnv 检查;对 Dockerfile 跑密钥扫描
为什么、来源
为什么:ARG 和 ENV 的值会留在镜像元数据和构建历史里,拿到镜像就能读出来。
有 Dockerfile 的目录必须有 .dockerignore,至少排除 .git、.env*、密钥文件、依赖和构建缓存目录
怎么检查:CI 检查 .dockerignore 存在并包含这些条目
为什么、来源
为什么:没有它时 COPY . . 会把 Git 历史和本地密钥一起拷进镜像,还会让构建缓存频繁失效。
用多阶段构建,运行阶段只拷贝运行所需的文件;先拷依赖清单和锁文件安装依赖,再拷源码
怎么检查:评审 Dockerfile 的阶段划分和 COPY 顺序;对比镜像体积
为什么、来源
为什么:编译工具和源码留在运行镜像里既增大体积又扩大攻击面;先装依赖能让源码改动时复用依赖层缓存。
极简的单阶段镜像(如只拷一个静态二进制)可以例外。
系统包安装和清理缓存放在同一个 RUN 里;CMD 和 ENTRYPOINT 用 JSON 数组写法
怎么检查:hadolint 的 DL3009(未清 apt 列表)、DL3015(未加 --no-install-recommends);docker build --check 的 JSONArgsRecommended
为什么、来源
为什么:分开写时缓存会留在上一层里;Shell 写法的 CMD 让进程收不到停止信号,容器无法优雅退出。
系统包是否锁具体版本(DL3008)看基础镜像的更新策略;锁了要有人定期升级,否则会因旧版本被源删除而构建失败。
仓库里的 SQL 文件用 SQLFluff 检查和格式化,.sqlfluff 入库并写明目标方言
怎么检查:CI 运行 sqlfluff lint;.sqlfluff 里必须有 dialect 设置
为什么、来源
为什么:不同数据库的 SQL 语法不同,不声明方言就只能按通用语法猜,结果既漏报又误报。
- SQLFluff 通过只说明格式和它能解析的语法没问题;用了方言特有语法的 SQL 要在目标数据库的实际版本上执行测试(见 17)。
- 兼容 PostgreSQL 的国产数据库不等于和 PostgreSQL 完全一致,同样要在目标库上实测。
- 有模板的 SQL(如 dbt、Jinja)要配置对应的 templater。
业务查询写明要取的列,不用 SELECT *;EXISTS (SELECT 1 ...) 这类不取列的写法除外
怎么检查:在 .sqlfluff 中启用 AM04(结果列数不确定)
为什么、来源
为什么:表加列或调整列顺序后,SELECT * 的结果会悄悄改变,调用方可能因此出错或多取敏感字段。
GitHub Actions 的 workflow 文件在 CI 上跑 actionlint 和 zizmor;GitLab CI 配置用官方 CI Lint 校验
怎么检查:CI 运行 actionlint 和 zizmor .github/workflows/;GitLab 调用项目的 CI Lint 接口
为什么、来源
为什么:通用的 YAML 检查看不懂 CI 语法和表达式;actionlint 查语法和类型,zizmor 查安全问题,两者互补。
GitLab 的 include、rules、变量要在项目上下文里才能完整校验,所以用实例自带的 CI Lint,而不是只跑 yamllint。
第三方 Action 和可复用 workflow 锁到完整的提交 SHA(或使用平台提供的 workflow 依赖锁文件),不只写 @v4、@main
怎么检查:zizmor 的 unpinned-uses 检查
为什么、来源
为什么:标签和分支随时可以被改指向;被投毒的 Action 会在你的流水线里用你的权限运行。
SHA 后面加注释写明对应的版本号,交给 Renovate 或 Dependabot 升级(见 03)。GitHub 的 workflow 依赖锁文件(.github/workflows/actions.lock)是新功能,确认所用方案支持后再用。
workflow 显式声明最小的 permissions,只给需要更高权限的 job 单独提升
怎么检查:zizmor 的 excessive-permissions 检查
为什么、来源
为什么:默认令牌的权限可能很宽;某一步被攻破时,权限越小损失越小。
来自 fork 的 PR 代码不和能读密钥或写权限的 job 混跑。GitLab 的敏感变量设为 protected 和 masked。
PR 标题、分支名、提交信息等不可信的事件文本不直接写进 run: 脚本,先放进环境变量再引用
怎么检查:zizmor 的 template-injection 检查
为什么、来源
为什么:${{ }} 表达式会在脚本执行前被原样替换进去,PR 标题里的一段 Shell 代码就会被执行。
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE"
Cron 表达式旁写明由哪个调度器执行、时区和字段数;改动重要的定时任务时,用目标调度器列出接下来的若干次触发时间核对
怎么检查:评审时检查注释和触发时间列表
为什么、来源
为什么:Linux cron、GitHub Actions、Quartz 的字段数和语法不同;时区不写清、遇到夏令时,任务可能不跑或跑两次。
- GitHub Actions 的 schedule 默认用 UTC,可以加
timezone 指定 IANA 时区;最短间隔 5 分钟,高峰时可能延迟。
- Linux cron 的「日」和「星期」两个字段都有限制时,满足任一就会触发。
- 定时任务的重复触发、漏触发和幂等见 18。
没有符合条件的规则。
Shell 静态检查
ShellCheck · GPL-3.0(作为独立命令行工具在 CI 里运行;要随产品分发时见 27)
备选:bash -n(只想快速查语法错误;不能替代 ShellCheck)
不推荐:只靠人工评审
Shell 格式化
shfmt · BSD-3-Clause
Shell 脚本测试
bats-core · MIT
PowerShell 检查与测试
PSScriptAnalyzer(静态检查)+ Pester(测试) · PSScriptAnalyzer 为 MIT,Pester 为 Apache-2.0
不推荐:用 ShellCheck 查 .ps1
任务入口
just · just 为 CC0-1.0;Task 为 MIT;GNU Make 为 GPL-3.0 或更高版本
备选:Task(需要任务依赖、按输入输出跳过已完成的任务,或希望在 Windows 上不依赖 Bash 时);GNU Make(C/C++ 项目要按文件增量构建,或已有成熟的 Makefile);npm scripts(只有一个 Node 包的项目)
不推荐:一层套一层的包装脚本;为几条命令写复杂的 Makefile
JSON Schema 校验
check-jsonschema · check-jsonschema 为 Apache-2.0
备选:应用内的 JSON Schema 校验库(配置在程序启动时校验,各语言的校验库见 10–14)
不推荐:只检查文件是合法的 JSON 或 YAML;Sourcemeta JSON Schema CLI(集成或分发时)
YAML 检查
yamllint · GPL-3.0(独立命令行工具)
不推荐:拿 yamllint 当 CI 编排的校验
JSON 与 YAML 格式化
Prettier · MIT
备选:Biome(TypeScript 项目已经用 Biome,JSON 交给它,YAML 仍用 Prettier)
不推荐:同一种文件用两个格式器
TOML 格式化与检查
Tombi · Tombi、Taplo 均为 MIT
备选:Taplo(存量项目已在用且没遇到问题;两者的配置不兼容,迁移要改配置)
不推荐:用 YAML 或通用格式器处理 TOML
Dockerfile 检查
hadolint + docker build --check · hadolint 为 GPL-3.0(独立命令行工具)
不推荐:只 lint 不构建
SQL 检查与格式化
SQLFluff · SQLFluff 为 MIT;pgFormatter 为 PostgreSQL License
备选:pgFormatter(只用 PostgreSQL、只需要格式化,不需要 lint)
不推荐:把 SQLFluff 当成 SQL 注入检查
GitHub Actions 配置检查
actionlint(语法)+ zizmor(安全) · actionlint、zizmor 均为 MIT
备选:GitLab CI Lint(用 GitLab CI 时)
不推荐:只跑 actionlint
✕ 错误做法:把 set -e 当 try/catch,以为任何失败都会让脚本退出
✓ 应该:预期会失败的命令用 if 显式处理
✕ 错误做法:for f in $(ls) 和不加引号的变量
✓ 应该:加引号;用通配符或 find -print0
✕ 错误做法:curl ... | bash 安装工具
✓ 应该:下载固定版本并校验哈希,或用包管理器
✕ 错误做法:用 sed 或正则改 JSON、YAML
✓ 应该:用 jq、yq 或语言里的解析库,改完用 Schema 校验
✕ 错误做法:CI 里另写一套和本地不同的 lint、构建命令
✓ 应该:CI 调用仓库的任务入口
✕ 错误做法:check 任务里顺手跑了格式化
✓ 应该:check 只读,格式化放在 format
✕ 错误做法:Dockerfile 用 ARG 传 API 令牌
✓ 应该:用 BuildKit secret 挂载
✕ 错误做法:FROM xxx:latest 用于正式发布
✓ 应该:标签加 digest 固定,由机器人提 PR 升级
✕ 错误做法:把 .env 提交进仓库或打进镜像
✓ 应该:只提交 .env.example,运行时由平台注入
✕ 错误做法:所有配置都用 YAML,也不配 Schema
✓ 应该:按消费者选格式,机器读的配置配 Schema
✕ 错误做法:run: echo "${{ github.event.pull_request.title }}"
✓ 应该:先放进 env,再用 "$PR_TITLE" 引用
✕ 错误做法:用在线 cron 网站验证所有调度器的表达式
✓ 应该:用目标调度器自己的解析结果核对