diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33fc1b2..dcd7cdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ on: - 'bun.lockb' - 'tsconfig*.json' - '.github/workflows/ci.yml' + - 'scripts/**' + - '.claude/skills/**/scripts/**' jobs: lint: @@ -26,6 +28,55 @@ jobs: - run: bun install - run: bun run lint + shellcheck: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + - name: Install shellcheck v0.10.0 + run: | + set -euo pipefail + url="https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz" + sha256="6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87" + curl -sSL --fail --retry 3 --retry-all-errors "$url" -o "$RUNNER_TEMP/shellcheck.tar.xz" + echo "$sha256 $RUNNER_TEMP/shellcheck.tar.xz" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/shellcheck.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/shellcheck-v0.10.0" >> "$GITHUB_PATH" + - name: Run shellcheck + run: | + set -euo pipefail + # severity=warning: `error` is a no-op gate (0 findings against this + # tree, before and after); `warning` caught the real issues (2 SC2034 + # in scripts/ship.sh, both fixed here) without the noise of `style` + # (5 on the current tree — all SC1091/SC2016/SC2317, none real + # defects). No --enable=all: SC2310/SC2311 fire zero times here, so + # enabling `all` would only add tens of info and hundreds of style + # findings (37 and 614 when this was written) for no signal on the + # case it was proposed for. + shopt -s nullglob globstar + + script_files=(scripts/**/*.sh) + if [ "${#script_files[@]}" -eq 0 ]; then + echo "::error::scripts/**/*.sh matched no files" + exit 1 + fi + + skill_files=(.claude/skills/**/scripts/**/*.sh) + if [ "${#skill_files[@]}" -eq 0 ]; then + echo "::error::.claude/skills/**/scripts/**/*.sh matched no files" + exit 1 + fi + + # shellcheck is silent when clean, so without this the log records no + # evidence of WHAT was checked. It also surfaces a partial-coverage + # regression that leaves both groups non-empty — the case the guards + # above cannot see. + echo "shellcheck: ${#script_files[@]} in scripts/, ${#skill_files[@]} in .claude/skills/" + + shellcheck --severity=warning "${script_files[@]}" "${skill_files[@]}" + unit-tests: strategy: matrix: diff --git a/scripts/ship.sh b/scripts/ship.sh index 7b4ae6a..1355cf1 100755 --- a/scripts/ship.sh +++ b/scripts/ship.sh @@ -38,9 +38,9 @@ promote_issues() { # GraphQL, which is eventually consistent — mergeCommit is briefly null. Retry # rather than hard-stop: past this point a bail leaves the release unpublished. resolve_merge_sha() { - local pr="$1" attempt + local pr="$1" _ MERGE_SHA="" - for attempt in $(seq 1 5); do + for _ in $(seq 1 5); do MERGE_SHA=$(gh pr view "$pr" --json mergeCommit --jq '.mergeCommit.oid // empty' 2>/dev/null || true) [ -n "$MERGE_SHA" ] && [ "$MERGE_SHA" != "null" ] && return 0 sleep 3 @@ -58,7 +58,7 @@ resolve_merge_sha() { tag_and_publish() { local sha="$1" version="$2" local tag="v$version" - local existing run attempt + local existing run _ # The merge commit is created server-side — fetch before tagging it. git fetch origin main --quiet || { @@ -102,7 +102,7 @@ tag_and_publish() { # registration lag, then fail loudly rather than exiting 0 on a guess. info "Waiting for publish workflow..." run="" - for attempt in $(seq 1 30); do + for _ in $(seq 1 30); do run=$(gh run list --workflow publish.yml --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true) [ -n "$run" ] && break sleep 10