diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f93cb306..004f78f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,9 @@ jobs: - name: Run quality gate run: npm run quality + - name: ShellCheck scripts and generated hook launchers + run: scripts/ci/shellcheck.sh + release-gate: name: release-gate / jedit-echo needs: [ plan ] diff --git a/CHANGELOG.md b/CHANGELOG.md index dff8a04b..18cb40d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- CI now runs ShellCheck (`scripts/ci/shellcheck.sh`, in the `quality` job) over + every tracked shell script and over the pre-commit and pre-push launchers that + `scripts/install-hooks.sh` generates, installed into a throwaway repository. + The ReplaceRange proof locks were refreshed for the changed workflow bytes. + - Removed the unused direct `@modelcontextprotocol/sdk` dependency declaration. No Jedit code imports it; the SDK remains installed as a dependency of `@flyingrobots/graft`, at the version (1.32.1) the security update selected. diff --git a/edict/replace-range/edict.build-lock.json b/edict/replace-range/edict.build-lock.json index 2489a46f..9e9c8f28 100644 --- a/edict/replace-range/edict.build-lock.json +++ b/edict/replace-range/edict.build-lock.json @@ -2,10 +2,10 @@ "schema": "jedit.edict-build-lock/v1", "applicationCoordinate": "jedit.text.replace_range@1", "operationCoordinate": "jedit.text.replace_range@1.replaceRange", - "toolchainLockSha256": "4b7eb54120bdb604dfcc946aec873d97d072960736020f1abf67d35de40376a7", + "toolchainLockSha256": "db54615c1294bd128401715630421e49b2633776ace7ede1bfb0c3c198a11d2a", "sourceClosure": { "schema": "jedit.edict-source-closure/v1", - "digest": "sha256:d1cd011a781bda5e8cd01ef0e615c9405565771983b33ebbd7f259b8b5d33ccd", + "digest": "sha256:c692181d166000e86e2bc2564e60814435f7ae7202b057fc9e7fb62974fee12b", "files": [ { "path": "application/edict.application.json", @@ -17,7 +17,7 @@ }, { "path": "application/edict.toolchain-lock.json", - "sha256": "4b7eb54120bdb604dfcc946aec873d97d072960736020f1abf67d35de40376a7" + "sha256": "db54615c1294bd128401715630421e49b2633776ace7ede1bfb0c3c198a11d2a" }, { "path": "application/src/ReplaceRange.edict", @@ -81,7 +81,7 @@ }, { "path": "project/.github/workflows/ci.yml", - "sha256": "bc19286ef44ce7d8ba0fd8a3e39a8ec0313d07ed3084fbe15e25ef42d82bc8f1" + "sha256": "b85f2e43552dab43dd9d8f87daacc0c73b437fc2bde341eb3fb21fd6e2394c12" }, { "path": "project/package.json", diff --git a/edict/replace-range/edict.toolchain-lock.json b/edict/replace-range/edict.toolchain-lock.json index 2de3a524..7b446c76 100644 --- a/edict/replace-range/edict.toolchain-lock.json +++ b/edict/replace-range/edict.toolchain-lock.json @@ -24,7 +24,7 @@ }, { "path": ".github/workflows/ci.yml", - "sha256": "bc19286ef44ce7d8ba0fd8a3e39a8ec0313d07ed3084fbe15e25ef42d82bc8f1" + "sha256": "b85f2e43552dab43dd9d8f87daacc0c73b437fc2bde341eb3fb21fd6e2394c12" } ] }, diff --git a/scripts/ci/lib/shebang.sh b/scripts/ci/lib/shebang.sh new file mode 100644 index 00000000..83b9a42e --- /dev/null +++ b/scripts/ci/lib/shebang.sh @@ -0,0 +1,37 @@ +# shellcheck shell=bash +# Sourced by scripts/ci/shellcheck.sh and its spec. Not a script itself. + +# Classifies a file by its shebang. For a plain shebang (`#!/path/interp ...` +# or `#!/usr/bin/env [NAME=value]... interp ...`) it prints the interpreter and +# returns 0. It returns 1 when the file has no shebang or no interpreter, and +# 2 for an `env` shebang it does not parse: one that uses env options (such as +# `-S` or `-u`), quotes, or backslash escapes. Emulating env's parser is how +# shell scripts get silently skipped, so callers must refuse status 2 rather +# than guess. +shebang_interpreter() { + local line word + local -a words + IFS= read -r line <"$1" || [[ -n "$line" ]] || return 1 + [[ "$line" == "#!"* ]] || return 1 + line="${line#\#!}" + read -r -a words <<<"$line" + ((${#words[@]})) || return 1 + if [[ "${words[0]##*/}" != env ]]; then + printf '%s\n' "${words[0]##*/}" + return 0 + fi + if [[ "$line" == *[\"\'\\]* ]]; then + return 2 + fi + for word in "${words[@]:1}"; do + case "$word" in + -*) return 2 ;; + *=*) ;; + *) + printf '%s\n' "${word##*/}" + return 0 + ;; + esac + done + return 1 +} diff --git a/scripts/ci/shellcheck.sh b/scripts/ci/shellcheck.sh new file mode 100755 index 00000000..b5e99597 --- /dev/null +++ b/scripts/ci/shellcheck.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# ShellCheck every tracked shell script, plus the hook launchers that +# scripts/install-hooks.sh generates. The launchers exist only inside an +# installed hooks directory, so they are installed into a throwaway +# repository and checked there. +# shellcheck source-path=SCRIPTDIR +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root" + +# shellcheck source=lib/shebang.sh +source "$root/scripts/ci/lib/shebang.sh" + +scripts=() +unparsed=() +while IFS= read -r -d '' path; do + case "$path" in + *.sh) scripts+=("$path") ;; + *) + [[ -f "$path" ]] || continue + status=0 + interpreter="$(shebang_interpreter "$path")" || status=$? + if ((status == 2)); then + unparsed+=("$path") + elif ((status == 0)); then + case "$interpreter" in + bash | sh) scripts+=("$path") ;; + esac + fi + ;; + esac +done < <(git ls-files -z) + +if ((${#unparsed[@]})); then + printf 'shellcheck: cannot tell whether %s is a shell script from its env shebang\n' "${unparsed[@]}" >&2 + echo "shellcheck: use a plain shebang such as '#!/usr/bin/env bash' or '#!/bin/sh' (env options, quotes, and escapes are not parsed), or give a shell script a .sh extension." >&2 + exit 1 +fi + +if ((${#scripts[@]} == 0)); then + echo "shellcheck: no tracked shell scripts found" >&2 + exit 1 +fi +shellcheck -x "${scripts[@]}" + +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +git -C "$scratch" init -q +mkdir -p "$scratch/scripts" +cp scripts/install-hooks.sh "$scratch/scripts/" +(cd "$scratch" && GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 scripts/install-hooks.sh >/dev/null) +shellcheck "$scratch/.git/hooks/pre-commit" "$scratch/.git/hooks/pre-push" + +echo "shellcheck: ${#scripts[@]} tracked scripts and 2 generated launchers are clean" diff --git a/spec/ci-shellcheck-shebang.spec.mjs b/spec/ci-shellcheck-shebang.spec.mjs new file mode 100644 index 00000000..a5bab5c1 --- /dev/null +++ b/spec/ci-shellcheck-shebang.spec.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { cleanGitEnv, git } from './git-fixture-helpers.mjs'; + +// scripts/ci/shellcheck.sh checks an extensionless tracked file when its +// shebang names bash or sh. shebang_interpreter classifies plain shebangs and +// reports env shebangs it does not parse (env options, quotes, escapes) so the +// driver refuses them instead of silently skipping a shell script. +const LIBRARY = path.join(process.cwd(), 'scripts', 'ci', 'lib', 'shebang.sh'); +const NO_INTERPRETER = ''; +const UNPARSED = ''; +const UNPARSED_STATUS = 2; + +const CASES = Object.freeze([ + ['#!/bin/sh', 'sh'], + ['#!/usr/bin/env bash', 'bash'], + ['#! /bin/bash', 'bash'], + ['#!/bin/bash -e', 'bash'], + ['#!/usr/bin/env FOO=1 sh', 'sh'], + ['#!/usr/bin/env python # sh', 'python'], + ['#!/usr/bin/env node # sh', 'node'], + ['#!/opt/bash-tools/python', 'python'], + ['#!/bin/dash', 'dash'], + ['#!/usr/bin/env', NO_INTERPRETER], + ['echo no shebang', NO_INTERPRETER], + ['#!/usr/bin/env -S bash -e', UNPARSED], + ['#!/usr/bin/env -i bash', UNPARSED], + ['#!/usr/bin/env -u FOO sh', UNPARSED], + ['#!/usr/bin/env -S -iu FOO bash', UNPARSED], + ['#!/usr/bin/env --split-string=-u"FOO BAR" bash', UNPARSED], + ['#!/usr/bin/env -S bash\\_-e', UNPARSED], + ['#!/usr/bin/env "bash"', UNPARSED], + ['#!/usr/bin/env -S python3 -u', UNPARSED], +]); + +function temporaryDirectory(t, prefix) { + const directory = realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function classify(directory, index, firstLine) { + const file = path.join(directory, `case-${index}`); + writeFileSync(file, `${firstLine}\necho body\n`); + const result = spawnSync('bash', ['-c', 'source "$1" && shebang_interpreter "$2"', 'shebang', LIBRARY, file], { + encoding: 'utf8', + }); + if (result.status === UNPARSED_STATUS) return UNPARSED; + return result.status === 0 ? result.stdout.trim() : NO_INTERPRETER; +} + +test('shebang_interpreter classifies plain shebangs and reports env forms it does not parse', (t) => { + const directory = temporaryDirectory(t, 'jedit-shebang-'); + + CASES.forEach(([firstLine, expected], index) => { + assert.equal(classify(directory, index, firstLine), expected, firstLine); + }); +}); + +test('the ShellCheck driver refuses a tracked file whose env shebang it cannot classify', (t) => { + const repo = temporaryDirectory(t, 'jedit-shellcheck-driver-'); + mkdirSync(path.join(repo, 'scripts', 'ci'), { recursive: true }); + cpSync(path.join(process.cwd(), 'scripts', 'ci'), path.join(repo, 'scripts', 'ci'), { recursive: true }); + writeFileSync(path.join(repo, 'run'), '#!/usr/bin/env -S bash -e\necho hi\n', { mode: 0o755 }); + git(repo, 'init', '-q'); + git(repo, 'add', '-A'); + + const result = spawnSync(path.join(repo, 'scripts', 'ci', 'shellcheck.sh'), [], { + cwd: repo, + encoding: 'utf8', + env: cleanGitEnv(), + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /cannot tell whether run is a shell script/); +});