Files
install-action/tools/tidy.sh
2025-02-04 01:29:05 +09:00

1077 lines
43 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0 OR MIT
# shellcheck disable=SC2046
set -CeEuo pipefail
IFS=$'\n\t'
trap -- 's=$?; printf >&2 "%s\n" "${0##*/}:${LINENO}: \`${BASH_COMMAND}\` exit with ${s}"; exit ${s}' ERR
trap -- 'printf >&2 "%s\n" "${0##*/}: trapped SIGINT"; exit 1' SIGINT
cd -- "$(dirname -- "$0")"/..
# USAGE:
# ./tools/tidy.sh
#
# Note: This script requires the following tools:
# - git
# - jq 1.6+
# - npm (node 18+)
# - python 3.6+
# - shfmt
# - shellcheck
# - cargo, rustfmt (if Rust code exists)
# - clang-format (if C/C++/Protobuf code exists)
# - parse-dockerfile <https://github.com/taiki-e/parse-dockerfile> (if Dockerfile exists)
#
# This script is shared with other repositories, so there may also be
# checks for files not included in this repository, but they will be
# skipped if the corresponding files do not exist.
retry() {
for i in {1..10}; do
if "$@"; then
return 0
else
sleep "${i}"
fi
done
"$@"
}
error() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
printf '::error::%s\n' "$*"
else
printf >&2 'error: %s\n' "$*"
fi
should_fail=1
}
warn() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
printf '::warning::%s\n' "$*"
else
printf >&2 'warning: %s\n' "$*"
fi
}
info() {
printf >&2 'info: %s\n' "$*"
}
print_fenced() {
printf '=======================================\n'
printf '%s' "$*"
printf '=======================================\n\n'
}
check_diff() {
if [[ -n "${CI:-}" ]]; then
if ! git --no-pager diff --exit-code "$@"; then
should_fail=1
fi
else
if ! git --no-pager diff --exit-code "$@" &>/dev/null; then
should_fail=1
fi
fi
}
check_config() {
if [[ ! -e "$1" ]]; then
error "could not found $1 in the repository root${2:-}"
fi
}
check_install() {
for tool in "$@"; do
if ! type -P "${tool}" >/dev/null; then
if [[ "${tool}" == 'python3' ]]; then
if type -P python >/dev/null; then
continue
fi
fi
error "'${tool}' is required to run this check"
return 1
fi
done
}
check_unused() {
local kind="$1"
shift
local res
res=$(ls_files "$@")
if [[ -n "${res}" ]]; then
error "the following files are unused because there is no ${kind}; consider removing them"
print_fenced "${res}"$'\n'
fi
}
check_alt() {
local recommended=$1
local not_recommended=$2
if [[ -n "$3" ]]; then
error "please use ${recommended} instead of ${not_recommended} for consistency"
print_fenced "$3"$'\n'
fi
}
check_hidden() {
local res
for file in "$@"; do
check_alt ".${file}" "${file}" "$(comm -23 <(ls_files "*${file}") <(ls_files "*.${file}"))"
done
}
sed_rhs_escape() {
sed 's/\\/\\\\/g; s/\&/\\\&/g; s/\//\\\//g' <<<"$1"
}
venv_install_yq() {
if [[ ! -e "${venv_bin}/yq${exe}" ]]; then
if [[ ! -d .venv ]]; then
"python${py_suffix}" -m venv .venv >&2
fi
info "installing yq to .venv using pip${py_suffix}"
"${venv_bin}/pip${py_suffix}${exe}" install yq >&2
fi
}
if [[ $# -gt 0 ]]; then
cat <<EOF
USAGE:
$0
EOF
exit 1
fi
exe=''
py_suffix=''
if type -P python3 >/dev/null; then
py_suffix=3
fi
venv_bin=.venv/bin
yq() {
venv_install_yq
"${venv_bin}/yq${exe}" "$@"
}
tomlq() {
venv_install_yq
"${venv_bin}/tomlq${exe}" "$@"
}
case "$(uname -s)" in
Linux)
if [[ "$(uname -o)" == 'Android' ]]; then
ostype=android
else
ostype=linux
fi
;;
Darwin) ostype=macos ;;
FreeBSD) ostype=freebsd ;;
NetBSD) ostype=netbsd ;;
OpenBSD) ostype=openbsd ;;
DragonFly) ostype=dragonfly ;;
SunOS)
if [[ "$(/usr/bin/uname -o)" == 'illumos' ]]; then
ostype=illumos
else
ostype=solaris
# Solaris /usr/bin/* are not POSIX-compliant (e.g., grep has no -q, -E, -F),
# and POSIX-compliant commands are in /usr/xpg{4,6,7}/bin.
# https://docs.oracle.com/cd/E88353_01/html/E37853/xpg-7.html
if [[ "${PATH}" != *'/usr/xpg4/bin'* ]]; then
export PATH="/usr/xpg4/bin:${PATH}"
fi
# GNU/BSD grep/sed is required to run some checks, but most checks are okay with other POSIX grep/sed.
# Solaris /usr/xpg4/bin/grep has -q, -E, -F, but no -o (non-POSIX).
# Solaris /usr/xpg4/bin/sed has no -E (POSIX.1-2024) yet.
for tool in sed grep; do
if type -P "g${tool}" >/dev/null; then
eval "${tool}() { g${tool} \"\$@\"; }"
fi
done
fi
;;
MINGW* | MSYS* | CYGWIN* | Windows_NT)
ostype=windows
exe=.exe
venv_bin=.venv/Scripts
if type -P jq >/dev/null; then
# https://github.com/jqlang/jq/issues/1854
_tmp=$(jq -r .a <<<'{}')
if [[ "${_tmp}" != 'null' ]]; then
_tmp=$(jq -b -r .a 2>/dev/null <<<'{}' || true)
if [[ "${_tmp}" == 'null' ]]; then
jq() { command jq -b "$@"; }
else
jq() { command jq "$@" | tr -d '\r'; }
fi
yq() {
venv_install_yq
"${venv_bin}/yq${exe}" "$@" | tr -d '\r'
}
tomlq() {
venv_install_yq
"${venv_bin}/tomlq${exe}" "$@" | tr -d '\r'
}
fi
fi
;;
*) error "unrecognized os type '$(uname -s)' for \`\$(uname -s)\`" ;;
esac
check_install git
exclude_from_ls_files=()
# - `find` lists symlinks. `! ( -name <dir> -prune )` (.i.e., ignore <dir>) are manually listed from .gitignore.
# - `git submodule status` lists submodules. Use sed to remove the first character indicates status ( |+|-).
# - `git ls-files --deleted` lists removed files.
while IFS=$'\n' read -r line; do exclude_from_ls_files+=("${line}"); done < <({
find . \! \( -name .git -prune \) \! \( -name target -prune \) \! \( -name .venv -prune \) \! \( -name tmp -prune \) -type l | cut -c3-
git submodule status | sed 's/^.//' | cut -d' ' -f2
git ls-files --deleted
} | LC_ALL=C sort -u)
exclude_from_ls_files_no_symlink=()
while IFS=$'\n' read -r line; do exclude_from_ls_files_no_symlink+=("${line}"); done < <({
git submodule status | sed 's/^.//' | cut -d' ' -f2
git ls-files --deleted
} | LC_ALL=C sort -u)
ls_files() {
if [[ "${1:-}" == '--include-symlink' ]]; then
shift
comm -23 <(git ls-files "$@" | LC_ALL=C sort) <(printf '%s\n' ${exclude_from_ls_files_no_symlink[@]+"${exclude_from_ls_files_no_symlink[@]}"})
else
comm -23 <(git ls-files "$@" | LC_ALL=C sort) <(printf '%s\n' ${exclude_from_ls_files[@]+"${exclude_from_ls_files[@]}"})
fi
}
# Rust (if exists)
if [[ -n "$(ls_files '*.rs')" ]]; then
info "checking Rust code style"
check_config .rustfmt.toml "; consider adding with reference to https://github.com/taiki-e/cargo-hack/blob/HEAD/.rustfmt.toml"
check_config .clippy.toml "; consider adding with reference to https://github.com/taiki-e/cargo-hack/blob/HEAD/.clippy.toml"
if check_install cargo jq python3; then
# `cargo fmt` cannot recognize files not included in the current workspace and modules
# defined inside macros, so run rustfmt directly.
# We need to use nightly rustfmt because we use the unstable formatting options of rustfmt.
rustc_version=$(rustc -vV | grep -E '^release:' | cut -d' ' -f2)
if [[ "${rustc_version}" =~ nightly|dev ]] || ! type -P rustup >/dev/null; then
if type -P rustup >/dev/null; then
retry rustup component add rustfmt &>/dev/null
fi
info "running \`rustfmt \$(git ls-files '*.rs')\`"
rustfmt $(ls_files '*.rs')
else
if type -P rustup >/dev/null; then
retry rustup component add rustfmt --toolchain nightly &>/dev/null
fi
info "running \`rustfmt +nightly \$(git ls-files '*.rs')\`"
rustfmt +nightly $(ls_files '*.rs')
fi
check_diff $(ls_files '*.rs')
cast_without_turbofish=$(grep -Fn '.cast()' $(ls_files '*.rs') || true)
if [[ -n "${cast_without_turbofish}" ]]; then
error "please replace \`.cast()\` with \`.cast::<type_name>()\`:"
printf '%s\n' "${cast_without_turbofish}"
fi
# Make sure that public Rust crates don't contain executables and binaries.
executables=''
binaries=''
metadata=$(cargo metadata --format-version=1 --no-deps)
root_manifest=''
if [[ -f Cargo.toml ]]; then
root_manifest=$(cargo locate-project --message-format=plain --manifest-path Cargo.toml)
fi
exclude=''
has_public_crate=''
has_root_crate=''
for pkg in $(jq -c '. as $metadata | .workspace_members[] as $id | $metadata.packages[] | select(.id == $id)' <<<"${metadata}"); do
eval "$(jq -r '@sh "publish=\(.publish) manifest_path=\(.manifest_path)"' <<<"${pkg}")"
if [[ "$(tomlq -c '.lints' "${manifest_path}")" == 'null' ]]; then
error "no [lints] table in ${manifest_path} please add '[lints]' with 'workspace = true'"
fi
# Publishing is unrestricted if null, and forbidden if an empty array.
if [[ -z "${publish}" ]]; then
continue
fi
has_public_crate=1
if [[ "${manifest_path}" == "${root_manifest}" ]]; then
has_root_crate=1
exclude=$(tomlq -r '.package.exclude[]' "${manifest_path}")
if ! grep -Eq '^/\.\*$' <<<"${exclude}"; then
error "top-level Cargo.toml of non-virtual workspace should have 'exclude' field with \"/.*\""
fi
if [[ -e tools ]] && ! grep -Eq '^/tools$' <<<"${exclude}"; then
error "top-level Cargo.toml of non-virtual workspace should have 'exclude' field with \"/tools\" if it exists"
fi
if [[ -e target-specs ]] && ! grep -Eq '^/target-specs$' <<<"${exclude}"; then
error "top-level Cargo.toml of non-virtual workspace should have 'exclude' field with \"/target-specs\" if it exists"
fi
fi
done
if [[ -n "${has_public_crate}" ]]; then
check_config .deny.toml "; consider adding with reference to https://github.com/taiki-e/cargo-hack/blob/HEAD/.deny.toml"
info "checking public crates don't contain executables and binaries"
for p in $(ls_files --include-symlink); do
# Skip directories.
if [[ -d "${p}" ]]; then
continue
fi
# Top-level hidden files/directories and tools/* are excluded from crates.io (ensured by the above check).
# TODO: fully respect exclude field in Cargo.toml.
case "${p}" in
.* | tools/* | target-specs/*) continue ;;
*/*) ;;
*)
# If there is no crate at root, executables at the repository root directory if always okay.
if [[ -z "${has_root_crate}" ]]; then
continue
fi
;;
esac
if [[ -x "${p}" ]]; then
executables+="${p}"$'\n'
fi
# Use `diff` instead of `file` because `file` treats an empty file as a binary.
# https://unix.stackexchange.com/questions/275516/is-there-a-convenient-way-to-classify-files-as-binary-or-text#answer-402870
if { diff .gitattributes "${p}" || true; } | grep -Eq '^Binary file'; then
binaries+="${p}"$'\n'
fi
done
if [[ -n "${executables}" ]]; then
error "file-permissions-check failed: executables are only allowed to be present in directories that are excluded from crates.io"
print_fenced "${executables}"
fi
if [[ -n "${binaries}" ]]; then
error "file-permissions-check failed: binaries are only allowed to be present in directories that are excluded from crates.io"
print_fenced "${binaries}"
fi
fi
fi
# Sync markdown to rustdoc.
first=1
for markdown in $(ls_files '*.md'); do
markers=$(grep -En '^<!-- tidy:sync-markdown-to-rustdoc:(start[^ ]*|end) -->' "${markdown}" || true)
# BSD wc's -l emits spaces before number.
if [[ ! "$(LC_ALL=C wc -l <<<"${markers}")" =~ ^\ *2$ ]]; then
if [[ -n "${markers}" ]]; then
error "inconsistent '<!-- tidy:sync-markdown-to-rustdoc:* -->' marker found in ${markdown}"
printf '%s\n' "${markers}"
fi
continue
fi
start_marker=$(head -n1 <<<"${markers}")
end_marker=$(head -n2 <<<"${markers}" | tail -n1)
if [[ "${start_marker}" == *"tidy:sync-markdown-to-rustdoc:end"* ]] || [[ "${end_marker}" == *"tidy:sync-markdown-to-rustdoc:start"* ]]; then
error "inconsistent '<!-- tidy:sync-markdown-to-rustdoc:* -->' marker found in ${markdown}"
printf '%s\n' "${markers}"
continue
fi
if [[ -n "${first}" ]]; then
first=''
info "syncing markdown to rustdoc"
fi
lib="${start_marker#*:<\!-- tidy:sync-markdown-to-rustdoc:start:}"
if [[ "${start_marker}" == "${lib}" ]]; then
error "missing path in '<!-- tidy:sync-markdown-to-rustdoc:start:<path> -->' marker in ${markdown}"
printf '%s\n' "${markers}"
continue
fi
lib="${lib% -->}"
lib="$(dirname -- "${markdown}")/${lib}"
markers=$(grep -En '^<!-- tidy:sync-markdown-to-rustdoc:(start[^ ]*|end) -->' "${lib}" || true)
# BSD wc's -l emits spaces before number.
if [[ ! "$(LC_ALL=C wc -l <<<"${markers}")" =~ ^\ *2$ ]]; then
if [[ -n "${markers}" ]]; then
error "inconsistent '<!-- tidy:sync-markdown-to-rustdoc:* -->' marker found in ${lib}"
printf '%s\n' "${markers}"
else
error "missing '<!-- tidy:sync-markdown-to-rustdoc:* -->' marker in ${lib}"
fi
continue
fi
start_marker=$(head -n1 <<<"${markers}")
end_marker=$(head -n2 <<<"${markers}" | tail -n1)
if [[ "${start_marker}" == *"tidy:sync-markdown-to-rustdoc:end"* ]] || [[ "${end_marker}" == *"tidy:sync-markdown-to-rustdoc:start"* ]]; then
error "inconsistent '<!-- tidy:sync-markdown-to-rustdoc:* -->' marker found in ${lib}"
printf '%s\n' "${markers}"
continue
fi
new='<!-- tidy:sync-markdown-to-rustdoc:start -->'$'\a'
empty_line_re='^ *$'
gfm_alert_re='^> {0,4}\[!.*\] *$'
rust_code_block_re='^ *```(rust|rs) *$'
code_block_attr=''
in_alert=''
first_line=1
ignore=''
while IFS='' read -rd$'\a' line; do
if [[ -n "${ignore}" ]]; then
if [[ "${line}" == '<!-- tidy:sync-markdown-to-rustdoc:ignore:end -->'* ]]; then
ignore=''
fi
continue
fi
if [[ -n "${first_line}" ]]; then
# Ignore start marker.
first_line=''
continue
elif [[ -n "${in_alert}" ]]; then
if [[ "${line}" =~ ${empty_line_re} ]]; then
in_alert=''
new+=$'\a'"</div>"$'\a'
fi
elif [[ "${line}" =~ ${gfm_alert_re} ]]; then
alert="${line#*[\!}"
alert="${alert%%]*}"
alert=$(tr '[:lower:]' '[:upper:]' <<<"${alert%%]*}")
alert_lower=$(tr '[:upper:]' '[:lower:]' <<<"${alert}")
case "${alert}" in
NOTE | TIP | IMPORTANT) alert_sign='ⓘ' ;;
WARNING | CAUTION) alert_sign='⚠' ;;
*)
error "unknown alert type '${alert}' found; please use one of the types listed in <https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts>"
new+="${line}"$'\a'
continue
;;
esac
in_alert=1
new+="<div class=\"rustdoc-alert rustdoc-alert-${alert_lower}\">"$'\a\a'
new+="> **${alert_sign} ${alert:0:1}${alert_lower:1}**"$'\a>\a'
continue
fi
if [[ "${line}" =~ ${rust_code_block_re} ]]; then
code_block_attr="${code_block_attr#<\!-- tidy:sync-markdown-to-rustdoc:code-block:}"
code_block_attr="${code_block_attr%% -->*}"
new+="${line/\`\`\`*/\`\`\`}${code_block_attr}"$'\a'
code_block_attr=''
continue
fi
if [[ -n "${code_block_attr}" ]]; then
error "'${code_block_attr}' ignored because there is no subsequent Rust code block"
code_block_attr=''
fi
if [[ "${line}" == '<!-- tidy:sync-markdown-to-rustdoc:code-block:'*' -->'* ]]; then
code_block_attr="${line}"
continue
fi
if [[ "${line}" == '<!-- tidy:sync-markdown-to-rustdoc:ignore:start -->'* ]]; then
if [[ "${new}" == *$'\a\a' ]]; then
new="${new%$'\a'}"
fi
ignore=1
continue
fi
new+="${line}"$'\a'
done < <(tr '\n' '\a' <"${markdown}" | grep -Eo '<!-- tidy:sync-markdown-to-rustdoc:start[^ ]* -->.*<!-- tidy:sync-markdown-to-rustdoc:end -->')
new+='<!-- tidy:sync-markdown-to-rustdoc:end -->'
new=$(tr '\n' '\a' <"${lib}" | sed "s/<!-- tidy:sync-markdown-to-rustdoc:start[^ ]* -->.*<!-- tidy:sync-markdown-to-rustdoc:end -->/$(sed_rhs_escape "${new}")/" | tr '\a' '\n')
printf '%s\n' "${new}" >|"${lib}"
check_diff "${lib}"
done
printf '\n'
else
check_unused "Rust code" '*.cargo*' '*clippy.toml' '*deny.toml' '*rustfmt.toml' '*Cargo.toml' '*Cargo.lock'
fi
check_hidden clippy.toml deny.toml rustfmt.toml
# C/C++/Protobuf (if exists)
clang_format_ext=('*.c' '*.h' '*.cpp' '*.hpp' '*.proto')
if [[ -n "$(ls_files "${clang_format_ext[@]}")" ]]; then
info "checking C/C++/Protobuf code style"
check_config .clang-format
if check_install clang-format; then
IFS=' '
info "running \`clang-format -i \$(git ls-files ${clang_format_ext[*]})\`"
IFS=$'\n\t'
clang-format -i $(ls_files "${clang_format_ext[@]}")
check_diff $(ls_files "${clang_format_ext[@]}")
fi
printf '\n'
else
check_unused "C/C++/Protobuf code" '*.clang-format*'
fi
check_alt '.clang-format' '_clang-format' "$(ls_files '*_clang-format')"
# https://gcc.gnu.org/onlinedocs/gcc/Overall-Options.html
check_alt '.cpp extension' 'other extensions' "$(ls_files '*.cc' '*.cp' '*.cxx' '*.C' '*.CPP' '*.c++')"
check_alt '.hpp extension' 'other extensions' "$(ls_files '*.hh' '*.hp' '*.hxx' '*.H' '*.HPP' '*.h++')"
# YAML/HTML/CSS/JavaScript/JSON (if exists)
prettier_ext=('*.css' '*.html' '*.js' '*.json' '*.yml' '*.yaml')
if [[ -n "$(ls_files "${prettier_ext[@]}")" ]]; then
info "checking YAML/HTML/CSS/JavaScript/JSON code style"
check_config .editorconfig
if [[ "${ostype}" == 'solaris' ]] && [[ -n "${CI:-}" ]] && ! type -P npm >/dev/null; then
warn "this check is skipped on Solaris due to no node 18+ in upstream package manager"
elif check_install npm; then
IFS=' '
info "running \`npx -y prettier -l -w \$(git ls-files ${prettier_ext[*]})\`"
IFS=$'\n\t'
npx -y prettier -l -w $(ls_files "${prettier_ext[@]}")
check_diff $(ls_files "${prettier_ext[@]}")
fi
printf '\n'
else
check_unused "YAML/HTML/CSS/JavaScript/JSON file" '*.prettierignore'
fi
# https://prettier.io/docs/en/configuration
check_alt '.editorconfig' 'other configs' "$(ls_files '*.prettierrc*' '*prettier.config.*')"
check_alt '.yml extension' '.yaml extension' "$(ls_files '*.yaml' | { grep -Fv '.markdownlint-cli2.yaml' || true; })"
# TOML (if exists)
if [[ -n "$(ls_files '*.toml' | { grep -Fv '.taplo.toml' || true; })" ]]; then
info "checking TOML style"
check_config .taplo.toml
if [[ "${ostype}" == 'solaris' ]] && [[ -n "${CI:-}" ]] && ! type -P npm >/dev/null; then
warn "this check is skipped on Solaris due to no node 18+ in upstream package manager"
elif check_install npm; then
info "running \`npx -y @taplo/cli fmt \$(git ls-files '*.toml')\`"
RUST_LOG=warn npx -y @taplo/cli fmt $(ls_files '*.toml')
check_diff $(ls_files '*.toml')
fi
printf '\n'
else
check_unused "TOML file" '*taplo.toml'
fi
check_hidden taplo.toml
# Markdown (if exists)
if [[ -n "$(ls_files '*.md')" ]]; then
info "checking markdown style"
check_config .markdownlint-cli2.yaml
if [[ "${ostype}" == 'solaris' ]] && [[ -n "${CI:-}" ]] && ! type -P npm >/dev/null; then
warn "this check is skipped on Solaris due to no node 18+ in upstream package manager"
elif check_install npm; then
info "running \`npx -y markdownlint-cli2 \$(git ls-files '*.md')\`"
if ! npx -y markdownlint-cli2 $(ls_files '*.md'); then
error "check failed; please resolve the above markdownlint error(s)"
fi
fi
printf '\n'
else
check_unused "markdown file" '*.markdownlint-cli2.yaml'
fi
# https://github.com/DavidAnson/markdownlint-cli2#configuration
check_alt '.markdownlint-cli2.yaml' 'other configs' "$(ls_files '*.markdownlint-cli2.jsonc' '*.markdownlint-cli2.cjs' '*.markdownlint-cli2.mjs' '*.markdownlint.*')"
check_alt '.md extension' '*.markdown extension' "$(ls_files '*.markdown')"
# Shell scripts
info "checking shell scripts"
shell_files=()
docker_files=()
bash_files=()
grep_ere_files=()
sed_ere_files=()
for p in $(ls_files '*.sh' '*Dockerfile*'); do
case "${p}" in
tests/fixtures/* | */tests/fixtures/* | *.json) continue ;;
esac
case "${p##*/}" in
*.sh)
shell_files+=("${p}")
re='^#!/.*bash'
if [[ "$(head -1 "${p}")" =~ ${re} ]]; then
bash_files+=("${p}")
fi
;;
*Dockerfile*)
docker_files+=("${p}")
bash_files+=("${p}") # TODO
;;
esac
if grep -Eq '(^|[^0-9A-Za-z\."'\''-])(grep) -[A-Za-z]*E[^\)]' "${p}"; then
grep_ere_files+=("${p}")
fi
if grep -Eq '(^|[^0-9A-Za-z\."'\''-])(sed) -[A-Za-z]*E[^\)]' "${p}"; then
sed_ere_files+=("${p}")
fi
done
workflows=()
actions=()
if [[ -d .github/workflows ]]; then
for p in .github/workflows/*.yml; do
workflows+=("${p}")
bash_files+=("${p}") # TODO
done
fi
if [[ -n "$(ls_files '*action.yml')" ]]; then
for p in $(ls_files '*action.yml'); do
if [[ "${p##*/}" == 'action.yml' ]]; then
actions+=("${p}")
if ! grep -Fq 'shell: sh' "${p}"; then
bash_files+=("${p}")
fi
fi
done
fi
# correctness
res=$({ grep -En '(\[\[ .* ]]|(^|[^\$])\(\(.*\)\))( +#| *$)' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "bare [[ ]] and (( )) may not work as intended: see https://github.com/koalaman/shellcheck/issues/2360 for more"
print_fenced "${res}"$'\n'
fi
# TODO: chmod|chown
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(basename|cat|cd|cp|dirname|ln|ls|mkdir|mv|pushd|rm|rmdir|tee|touch)( +-[0-9A-Za-z]+)* +[^<>\|-]' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "use \`--\` before path(s): see https://github.com/koalaman/shellcheck/issues/2707 / https://github.com/koalaman/shellcheck/issues/2612 / https://github.com/koalaman/shellcheck/issues/2305 / https://github.com/koalaman/shellcheck/issues/2157 / https://github.com/koalaman/shellcheck/issues/2121 / https://github.com/koalaman/shellcheck/issues/314 for more"
print_fenced "${res}"$'\n'
fi
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(LINES|RANDOM|PWD)=' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "do not modify these built-in bash variables: see https://github.com/koalaman/shellcheck/issues/2160 / https://github.com/koalaman/shellcheck/issues/2559 for more"
print_fenced "${res}"$'\n'
fi
# perf
res=$({ grep -En '(^|[^\\])\$\((cat) ' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "use faster \`\$(<file)\` instead of \$(cat -- file): see https://github.com/koalaman/shellcheck/issues/2493 for more"
print_fenced "${res}"$'\n'
fi
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(command +-[vV]) ' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "use faster \`type -P\` instead of \`command -v\`: see https://github.com/koalaman/shellcheck/issues/1162 for more"
print_fenced "${res}"$'\n'
fi
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(type) +-P +[^ ]+ +&>' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "\`type -P\` doesn't output to stderr; use \`>\` instead of \`&>\`"
print_fenced "${res}"$'\n'
fi
# TODO: multi-line case
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(echo|printf )[^;)]* \|[^\|]' "${bash_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "use faster \`<<<...\` instead of \`echo ... |\`/\`printf ... |\`: see https://github.com/koalaman/shellcheck/issues/2593 for more"
print_fenced "${res}"$'\n'
fi
# style
if [[ ${#grep_ere_files[@]} -gt 0 ]]; then
# We intentionally do not check for occurrences in any other order (e.g., -iE, -i -E) here.
# This enforces the style and makes it easier to search.
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(grep) +([^-]|-[^EFP-]|--[^hv])' "${grep_ere_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "please always use ERE (grep -E) instead of BRE for code consistency within a file"
print_fenced "${res}"$'\n'
fi
fi
if [[ ${#sed_ere_files[@]} -gt 0 ]]; then
res=$({ grep -En '(^|[^0-9A-Za-z\."'\''-])(sed) +([^-]|-[^E-]|--[^hv])' "${sed_ere_files[@]}" || true; } | { grep -Ev '^[^ ]+: *(#|//)' || true; } | LC_ALL=C sort)
if [[ -n "${res}" ]]; then
error "please always use ERE (sed -E) instead of BRE for code consistency within a file"
print_fenced "${res}"$'\n'
fi
fi
if check_install shfmt; then
check_config .editorconfig
info "running \`shfmt -w \$(git ls-files '*.sh')\`"
if ! shfmt -w "${shell_files[@]}"; then
error "check failed; please resolve the shfmt error(s)"
fi
check_diff "${shell_files[@]}"
fi
if [[ "${ostype}" == 'solaris' ]] && [[ -n "${CI:-}" ]] && ! type -P shellcheck >/dev/null; then
warn "this check is skipped on Solaris due to no haskell/shellcheck in upstream package manager"
elif check_install shellcheck; then
check_config .shellcheckrc
info "running \`shellcheck \$(git ls-files '*.sh')\`"
if ! shellcheck "${shell_files[@]}"; then
error "check failed; please resolve the above shellcheck error(s)"
fi
# Check scripts in dockerfile.
if [[ ${#docker_files[@]} -gt 0 ]]; then
# Exclude SC2096 due to the way the temporary script is created.
shellcheck_exclude=SC2096
info "running \`shellcheck --exclude ${shellcheck_exclude}\` for scripts in \$(git ls-files '*Dockerfile*')\`"
if [[ "${ostype}" == 'windows' ]]; then
# No such file or directory: '/proc/N/fd/N'
warn "this check is skipped on Windows due to upstream bug (failed to found fd created by <())"
elif [[ "${ostype}" == 'dragonfly' ]]; then
warn "this check is skipped on DragonFly BSD due to upstream bug (hang)"
elif check_install jq python3 parse-dockerfile; then
shellcheck_for_dockerfile() {
local text=$1
local shell=$2
local display_path=$3
if [[ "${text}" == 'null' ]]; then
return
fi
text="#!${shell}"$'\n'"${text}"
case "${ostype}" in
windows) text=${text//\r/} ;;
esac
local color=auto
if [[ -t 1 ]] || [[ -n "${GITHUB_ACTIONS:-}" ]]; then
color=always
fi
if ! shellcheck --color="${color}" --exclude "${shellcheck_exclude}" <(printf '%s\n' "${text}") | sed "s/\/dev\/fd\/[0-9][0-9]*/$(sed_rhs_escape "${display_path}")/g"; then
error "check failed; please resolve the above shellcheck error(s)"
fi
}
for dockerfile_path in ${docker_files[@]+"${docker_files[@]}"}; do
dockerfile=$(parse-dockerfile "${dockerfile_path}")
normal_shell=''
for instruction in $(jq -c '.instructions[]' <<<"${dockerfile}"); do
instruction_kind=$(jq -r '.kind' <<<"${instruction}")
case "${instruction_kind}" in
FROM)
# https://docs.docker.com/reference/dockerfile/#from
# > Each FROM instruction clears any state created by previous instructions.
normal_shell=''
continue
;;
ADD | ARG | CMD | COPY | ENTRYPOINT | ENV | EXPOSE | HEALTHCHECK | LABEL) ;;
# https://docs.docker.com/reference/build-checks/maintainer-deprecated/
MAINTAINER) error "MAINTAINER instruction is deprecated in favor of using label" ;;
RUN) ;;
SHELL)
normal_shell=''
for argument in $(jq -c '.arguments[]' <<<"${instruction}"); do
value=$(jq -r '.value' <<<"${argument}")
if [[ -z "${normal_shell}" ]]; then
case "${value}" in
cmd | cmd.exe | powershell | powershell.exe)
# not unix shell
normal_shell="${value}"
break
;;
esac
else
normal_shell+=' '
fi
normal_shell+="${value}"
done
;;
STOPSIGNAL | USER | VOLUME | WORKDIR) ;;
*) error "unknown instruction ${instruction_kind}" ;;
esac
arguments=''
# only shell-form RUN/ENTRYPOINT/CMD is run in a shell
case "${instruction_kind}" in
RUN)
if [[ "$(jq -r '.arguments.shell' <<<"${instruction}")" == 'null' ]]; then
continue
fi
arguments=$(jq -r '.arguments.shell.value' <<<"${instruction}")
if [[ -z "${arguments}" ]]; then
if [[ "$(jq -r '.here_docs[0]' <<<"${instruction}")" == 'null' ]]; then
error "empty RUN is useless (${dockerfile_path})"
continue
fi
if [[ "$(jq -r '.here_docs[1]' <<<"${instruction}")" != 'null' ]]; then
# TODO:
error "multi here-docs without command is not yet supported (${dockerfile_path})"
fi
arguments=$(jq -r '.here_docs[0].value' <<<"${instruction}")
if [[ "${arguments}" == '#!'* ]]; then
# TODO:
error "here-docs with shebang is not yet supported (${dockerfile_path})"
continue
fi
else
if [[ "$(jq -r '.here_docs[0]' <<<"${instruction}")" != 'null' ]]; then
# TODO:
error "sh/bash command with here-docs is not yet checked (${dockerfile_path})"
fi
fi
;;
ENTRYPOINT | CMD)
if [[ "$(jq -r '.arguments.shell' <<<"${instruction}")" == 'null' ]]; then
continue
fi
arguments=$(jq -r '.arguments.shell.value' <<<"${instruction}")
if [[ -z "${normal_shell}" ]] && [[ -n "${arguments}" ]]; then
# https://docs.docker.com/reference/build-checks/json-args-recommended/
error "JSON arguments recommended for ENTRYPOINT/CMD to prevent unintended behavior related to OS signals"
fi
;;
HEALTHCHECK)
if [[ "$(jq -r '.arguments.kind' <<<"${instruction}")" != "CMD" ]]; then
continue
fi
if [[ "$(jq -r '.arguments.arguments.shell' <<<"${instruction}")" == 'null' ]]; then
continue
fi
arguments=$(jq -r '.arguments.arguments.shell.value' <<<"${instruction}")
;;
*) continue ;;
esac
case "${normal_shell}" in
# not unix shell
cmd | cmd.exe | powershell | powershell.exe) continue ;;
# https://docs.docker.com/reference/dockerfile/#shell
'') shell='/bin/sh -c' ;;
*) shell="${normal_shell}" ;;
esac
shellcheck_for_dockerfile "${arguments}" "${shell}" "${dockerfile_path}"
done
done
fi
fi
# Check scripts in YAML.
if [[ ${#workflows[@]} -gt 0 ]] || [[ ${#actions[@]} -gt 0 ]]; then
# Exclude SC2096 due to the way the temporary script is created.
shellcheck_exclude=SC2086,SC2096,SC2129
info "running \`shellcheck --exclude ${shellcheck_exclude}\` for scripts in .github/workflows/*.yml and **/action.yml"
if [[ "${ostype}" == 'windows' ]]; then
# No such file or directory: '/proc/N/fd/N'
warn "this check is skipped on Windows due to upstream bug (failed to found fd created by <())"
elif [[ "${ostype}" == 'dragonfly' ]]; then
warn "this check is skipped on DragonFly BSD due to upstream bug (hang)"
elif check_install jq python3; then
shellcheck_for_gha() {
local text=$1
local shell=$2
local display_path=$3
if [[ "${text}" == 'null' ]]; then
return
fi
case "${shell}" in
bash* | sh*) ;;
*) return ;;
esac
# Use python because sed doesn't support .*?.
text=$(
"python${py_suffix}" - <(printf '%s\n%s' "#!/usr/bin/env ${shell%' {0}'}" "${text}") <<EOF
import re
import sys
with open(sys.argv[1], 'r') as f:
text = f.read()
text = re.sub(r"\\\${{.*?}}", "\${__GHA_SYNTAX__}", text)
print(text)
EOF
)
case "${ostype}" in
windows) text=${text//\r/} ;;
esac
local color=auto
if [[ -t 1 ]] || [[ -n "${GITHUB_ACTIONS:-}" ]]; then
color=always
fi
if ! shellcheck --color="${color}" --exclude "${shellcheck_exclude}" <(printf '%s\n' "${text}") | sed "s/\/dev\/fd\/[0-9][0-9]*/$(sed_rhs_escape "${display_path}")/g"; then
error "check failed; please resolve the above shellcheck error(s)"
fi
}
for workflow_path in ${workflows[@]+"${workflows[@]}"}; do
workflow=$(yq -c '.' "${workflow_path}")
# The top-level permissions must be weak as they are referenced by all jobs.
permissions=$(jq -c '.permissions' <<<"${workflow}")
case "${permissions}" in
'{"contents":"read"}' | '{"contents":"none"}') ;;
null) error "${workflow_path}: top level permissions not found; it must be 'contents: read' or weaker permissions" ;;
*) error "${workflow_path}: only 'contents: read' and weaker permissions are allowed at top level, but found '${permissions}'; if you want to use stronger permissions, please set job-level permissions" ;;
esac
default_shell=$(jq -r -c '.defaults.run.shell' <<<"${workflow}")
# github's default is https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrunshell
re='^bash --noprofile --norc -CeEux?o pipefail \{0}$'
if [[ ! "${default_shell}" =~ ${re} ]]; then
error "${workflow_path}: defaults.run.shell should be 'bash --noprofile --norc -CeEuxo pipefail {0}' or 'bash --noprofile --norc -CeEuo pipefail {0}'"
continue
fi
# .steps == null means the job is the caller of reusable workflow
for job in $(jq -c '.jobs | to_entries[] | select(.value.steps)' <<<"${workflow}"); do
name=$(jq -r '.key' <<<"${job}")
job=$(jq -r '.value' <<<"${job}")
n=0
job_default_shell=$(jq -r '.defaults.run.shell' <<<"${job}")
if [[ "${job_default_shell}" == 'null' ]]; then
job_default_shell="${default_shell}"
fi
for step in $(jq -c '.steps[]' <<<"${job}"); do
prepare=''
eval "$(jq -r 'if .run then @sh "RUN=\(.run) shell=\(.shell)" else @sh "RUN=\(.with.run) prepare=\(.with.prepare) shell=\(.with.shell)" end' <<<"${step}")"
if [[ "${RUN}" == 'null' ]]; then
_=$((n++))
continue
fi
if [[ "${shell}" == 'null' ]]; then
if [[ -z "${prepare}" ]]; then
shell="${job_default_shell}"
elif grep -Eq '^ *chsh +-s +[^ ]+/bash' <<<"${prepare}"; then
shell='bash'
else
shell='sh'
fi
fi
shellcheck_for_gha "${RUN}" "${shell}" "${workflow_path} ${name}.steps[${n}].run"
shellcheck_for_gha "${prepare:-null}" 'sh' "${workflow_path} ${name}.steps[${n}].run"
_=$((n++))
done
done
done
for action_path in ${actions[@]+"${actions[@]}"}; do
runs=$(yq -c '.runs' "${action_path}")
if [[ "$(jq -r '.using' <<<"${runs}")" != "composite" ]]; then
continue
fi
n=0
for step in $(jq -c '.steps[]' <<<"${runs}"); do
prepare=''
eval "$(jq -r 'if .run then @sh "RUN=\(.run) shell=\(.shell)" else @sh "RUN=\(.with.run) prepare=\(.with.prepare) shell=\(.with.shell)" end' <<<"${step}")"
if [[ "${RUN}" == 'null' ]]; then
_=$((n++))
continue
fi
if [[ "${shell}" == 'null' ]]; then
if [[ -z "${prepare}" ]]; then
error "\`shell: ..\` is required"
continue
elif grep -Eq '^ *chsh +-s +[^ ]+/bash' <<<"${prepare}"; then
shell='bash'
else
shell='sh'
fi
fi
shellcheck_for_gha "${RUN}" "${shell}" "${action_path} steps[${n}].run"
shellcheck_for_gha "${prepare:-null}" 'sh' "${action_path} steps[${n}].run"
_=$((n++))
done
done
fi
fi
fi
printf '\n'
check_alt '.sh extension' '*.bash extension' "$(ls_files '*.bash')"
# License check
# TODO: This check is still experimental and does not track all files that should be tracked.
if [[ -f tools/.tidy-check-license-headers ]]; then
info "checking license headers (experimental)"
failed_files=''
for p in $(comm -12 <(eval $(<tools/.tidy-check-license-headers) | LC_ALL=C sort) <(ls_files | LC_ALL=C sort)); do
case "${p##*/}" in
*.stderr | *.expanded.rs) continue ;; # generated files
*.json) continue ;; # no comment support
*.sh | *.py | *.rb | *Dockerfile*) prefix=('# ') ;;
*.rs | *.c | *.h | *.cpp | *.hpp | *.s | *.S | *.js) prefix=('// ' '/* ') ;;
*.ld | *.x) prefix=('/* ') ;;
# TODO: More file types?
*) continue ;;
esac
# TODO: The exact line number is not actually important; it is important
# that it be part of the top-level comments of the file.
line=1
if IFS= LC_ALL=C read -rd '' -n3 shebang <"${p}" && [[ "${shebang}" == '#!/' ]]; then
line=2
elif [[ "${p}" == *'Dockerfile'* ]] && IFS= LC_ALL=C read -rd '' -n9 syntax <"${p}" && [[ "${syntax}" == '# syntax=' ]]; then
line=2
fi
header_found=''
for pre in "${prefix[@]}"; do
# TODO: check that the license is valid as SPDX and is allowed in this project.
if [[ "$(grep -Fn "${pre}SPDX-License-Identifier: " "${p}")" == "${line}:${pre}SPDX-License-Identifier: "* ]]; then
header_found=1
break
fi
done
if [[ -z "${header_found}" ]]; then
failed_files+="${p}:${line}"$'\n'
fi
done
if [[ -n "${failed_files}" ]]; then
error "license-check failed: please add SPDX-License-Identifier to the following files"
print_fenced "${failed_files}"
else
printf '\n'
fi
fi
# Spell check (if config exists)
if [[ -f .cspell.json ]]; then
info "spell checking"
project_dictionary=.github/.cspell/project-dictionary.txt
if [[ "${ostype}" == 'solaris' ]] && [[ -n "${CI:-}" ]] && ! type -P npm >/dev/null; then
warn "this check is skipped on Solaris due to no node 18+ in upstream package manager"
elif [[ "${ostype}" == 'illumos' ]]; then
warn "this check is skipped on illumos due to upstream bug (dictionaries are not loaded correctly)"
elif check_install npm jq python3; then
has_rust=''
if [[ -n "$(ls_files '*Cargo.toml')" ]]; then
has_rust=1
dependencies=''
for manifest_path in $(ls_files '*Cargo.toml'); do
if [[ "${manifest_path}" != "Cargo.toml" ]] && [[ "$(tomlq -c '.workspace' "${manifest_path}")" == 'null' ]]; then
continue
fi
m=$(cargo metadata --format-version=1 --no-deps --manifest-path "${manifest_path}" || true)
if [[ -z "${m}" ]]; then
continue # Ignore broken manifest
fi
dependencies+="$(jq -r '. as $metadata | .workspace_members[] as $id | $metadata.packages[] | select(.id == $id) | .dependencies[].name' <<<"${m}")"$'\n'
done
dependencies=$(LC_ALL=C sort -f -u <<<"${dependencies//[0-9_-]/$'\n'}")
fi
config_old=$(<.cspell.json)
config_new=$({ grep -Ev '^ *//' <<<"${config_old}" || true; } | jq 'del(.dictionaries[] | select(index("organization-dictionary") | not)) | del(.dictionaryDefinitions[] | select(.name == "organization-dictionary" | not))')
trap -- 'printf "%s\n" "${config_old}" >|.cspell.json; printf >&2 "%s\n" "${0##*/}: trapped SIGINT"; exit 1' SIGINT
printf '%s\n' "${config_new}" >|.cspell.json
dependencies_words=''
if [[ -n "${has_rust}" ]]; then
dependencies_words=$(npx -y cspell stdin --no-progress --no-summary --words-only --unique <<<"${dependencies}" || true)
fi
all_words=$(ls_files | { grep -Fv "${project_dictionary}" || true; } | npx -y cspell --file-list stdin --no-progress --no-summary --words-only --unique || true)
printf '%s\n' "${config_old}" >|.cspell.json
trap -- 'printf >&2 "%s\n" "${0##*/}: trapped SIGINT"; exit 1' SIGINT
cat >|.github/.cspell/rust-dependencies.txt <<EOF
// This file is @generated by ${0##*/}.
// It is not intended for manual editing.
EOF
if [[ -n "${dependencies_words}" ]]; then
LC_ALL=C sort -f >>.github/.cspell/rust-dependencies.txt <<<"${dependencies_words}"$'\n'
fi
if [[ -z "${CI:-}" ]]; then
REMOVE_UNUSED_WORDS=1
fi
if [[ -z "${REMOVE_UNUSED_WORDS:-}" ]]; then
check_diff .github/.cspell/rust-dependencies.txt
fi
if ! grep -Fq '.github/.cspell/rust-dependencies.txt linguist-generated' .gitattributes; then
error "you may want to mark .github/.cspell/rust-dependencies.txt linguist-generated"
fi
info "running \`git ls-files | npx -y cspell --file-list stdin --no-progress --no-summary\`"
if ! ls_files | npx -y cspell --file-list stdin --no-progress --no-summary; then
error "spellcheck failed: please fix uses of below words or add to ${project_dictionary} if correct"
printf '=======================================\n'
{ ls_files | npx -y cspell --file-list stdin --no-progress --no-summary --words-only || true; } | sed "s/'s$//g" | LC_ALL=C sort -f -u
printf '=======================================\n\n'
fi
# Make sure the project-specific dictionary does not contain duplicated words.
for dictionary in .github/.cspell/*.txt; do
if [[ "${dictionary}" == "${project_dictionary}" ]]; then
continue
fi
case "${ostype}" in
# NetBSD uniq doesn't support -i flag.
netbsd) dup=$(sed '/^$/d; /^\/\//d' "${project_dictionary}" "${dictionary}" | LC_ALL=C sort -f | tr '[:upper:]' '[:lower:]' | LC_ALL=C uniq -d) ;;
*) dup=$(sed '/^$/d; /^\/\//d' "${project_dictionary}" "${dictionary}" | LC_ALL=C sort -f | LC_ALL=C uniq -d -i) ;;
esac
if [[ -n "${dup}" ]]; then
error "duplicated words in dictionaries; please remove the following words from ${project_dictionary}"
print_fenced "${dup}"$'\n'
fi
done
# Make sure the project-specific dictionary does not contain unused words.
if [[ -n "${REMOVE_UNUSED_WORDS:-}" ]]; then
grep_args=()
for word in $(grep -Ev '^//' "${project_dictionary}" || true); do
if ! grep -Eqi "^${word}$" <<<"${all_words}"; then
grep_args+=(-e "^${word}$")
fi
done
if [[ ${#grep_args[@]} -gt 0 ]]; then
info "removing unused words from ${project_dictionary}"
res=$(grep -Ev "${grep_args[@]}" "${project_dictionary}" || true)
if [[ -n "${res}" ]]; then
printf '%s\n' "${res}" >|"${project_dictionary}"
else
printf '' >|"${project_dictionary}"
fi
fi
else
unused=''
for word in $(grep -Ev '^//' "${project_dictionary}" || true); do
if ! grep -Eqi "^${word}$" <<<"${all_words}"; then
unused+="${word}"$'\n'
fi
done
if [[ -n "${unused}" ]]; then
error "unused words in dictionaries; please remove the following words from ${project_dictionary} or run ${0##*/} locally"
print_fenced "${unused}"
fi
fi
fi
printf '\n'
fi
if [[ -n "${should_fail:-}" ]]; then
exit 1
fi