Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions edict/replace-range/edict.build-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -17,7 +17,7 @@
},
{
"path": "application/edict.toolchain-lock.json",
"sha256": "4b7eb54120bdb604dfcc946aec873d97d072960736020f1abf67d35de40376a7"
"sha256": "db54615c1294bd128401715630421e49b2633776ace7ede1bfb0c3c198a11d2a"
},
{
"path": "application/src/ReplaceRange.edict",
Expand Down Expand Up @@ -81,7 +81,7 @@
},
{
"path": "project/.github/workflows/ci.yml",
"sha256": "bc19286ef44ce7d8ba0fd8a3e39a8ec0313d07ed3084fbe15e25ef42d82bc8f1"
"sha256": "b85f2e43552dab43dd9d8f87daacc0c73b437fc2bde341eb3fb21fd6e2394c12"
},
{
"path": "project/package.json",
Expand Down
2 changes: 1 addition & 1 deletion edict/replace-range/edict.toolchain-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
{
"path": ".github/workflows/ci.yml",
"sha256": "bc19286ef44ce7d8ba0fd8a3e39a8ec0313d07ed3084fbe15e25ef42d82bc8f1"
"sha256": "b85f2e43552dab43dd9d8f87daacc0c73b437fc2bde341eb3fb21fd6e2394c12"
}
]
},
Expand Down
37 changes: 37 additions & 0 deletions scripts/ci/lib/shebang.sh
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions scripts/ci/shellcheck.sh
Original file line number Diff line number Diff line change
@@ -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"
80 changes: 80 additions & 0 deletions spec/ci-shellcheck-shebang.spec.mjs
Original file line number Diff line number Diff line change
@@ -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 = '<none>';
const UNPARSED = '<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/);
});
Loading