Skip to content

fix(ci): turn shellcheck on in workflow-lint and clear the 44 findings (#1130) - #1148

Merged
frankbria merged 1 commit into
mainfrom
fix/1130-shellcheck-workflow-lint
Aug 11, 2026
Merged

fix(ci): turn shellcheck on in workflow-lint and clear the 44 findings (#1130)#1148
frankbria merged 1 commit into
mainfrom
fix/1130-shellcheck-workflow-lint

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #1130.

The headline finding is not a style nit

The issue guessed the SC1072/SC1073 parse errors at deploy.yml:570 were a ${{ }} false positive. They are not, and what they were pointing at is a real defect.

deploy.yml's ssh blocks use an unquoted heredoc delimiter (<< ENDSSH), so the runner's shell expands the body before sending it — including lines that start with #. Inside a heredoc those are data, not shell comments. Two prose comments contained backticks:

# `pm2 restart <name>` silently drops newly-added .env vars
# Delete by name (NOT `pm2 delete all`) to spare unrelated apps

Both were executed as command substitutions on the GitHub runner, with the step's secrets in the environment. Reproduced standalone before changing anything:

$ cat << ENDSSH
    # `echo EXECUTED_ON_CLIENT` in a comment
    # `pm2 restart <name>` silently drops vars
  ENDSSH
bash: command substitution: line 1: syntax error near unexpected token `newline'
bash: command substitution: line 1: `pm2 restart <name>'
  # EXECUTED_ON_CLIENT in a comment
  #  silently drops vars

pm2 is absent from the runner so nothing actually ran, and <name> failed as a redirection — which is precisely the SC1073 shellcheck was reporting. The comment also reached the server with its text silently rewritten. Only shellcheck sees this class; actionlint alone never would.

Triage of the remaining 43

Code n Resolution
SC2086 29 Quoted. Mostly >> $GITHUB_STEP_SUMMARY
SC2087 / SC2029 4 Deliberate — disabled inline with a reason. NEXT_PUBLIC_*, BACKEND_NAME/FRONTEND_NAME, ENV_BASE64 and REMOTE_PATH exist only on the runner and must be baked in; the forms that resolve server-side are already escaped \${...}. Quoting the delimiter would break the deploy
SC2129 3 The three summary blocks became grouped { ... } >> "$GITHUB_STEP_SUMMARY"
SC2006 3 The backticks above; gone with them
SC2044 1 Second real bug. The hardcoded-URL check looped over $(find ...), so any path with a space split into two non-existent files and every grep below it silently missed. Now while IFS= read -r with process substitution, which also keeps ISSUES accumulating in the current shell rather than a subshell
SC2002 1 Useless cat

The gate now fails rather than going hollow

./actionlint -shellcheck=./actionlint, plus a guard: actionlint skips every shell finding when shellcheck is absent from PATH, without warning. That is not theoretical — it is exactly why #1122's local verification was invalid on my machine. The step now asserts the binary exists and emits ::error:: if not.

tests/test_workflow_lint_wiring_1122.py flips accordingly: the suppression must be gone, and the missing-binary guard must be present.

Verification

  • actionlint (with shellcheck on PATH): 0 findings, was 44
  • Staging deploy re-run from this branch — see the comment below; the AC says explicitly that a deploy.yml change cannot be validated by CI alone
  • Full backend suite + ruff — reported in the comments

Note on scope

Every deploy.yml change here is either quoting, a grouped redirect, or a comment. No control flow, no command, and no expansion site changed — the escaped/unescaped split that decides client-vs-server resolution is exactly as it was. That is the property the staging deploy re-run checks.

#1130)

#1122 shipped `workflow-lint` with `-shellcheck=`, checking only whether a
workflow compiles, because shellcheck reported 44 pre-existing findings — almost
all in deploy.yml, where quoting changes cannot be verified from a PR.

The issue guessed the SC1072/SC1073 parse errors were a `${{ }}` false positive.
They are not, and the real defect is worse than a style nit. deploy.yml's ssh
blocks use an UNQUOTED heredoc delimiter, so the runner's shell expands the
body — including lines that begin with `#`, which are heredoc data, not shell
comments. Two prose comments contained backticks:

    # `pm2 restart <name>` silently drops newly-added .env vars
    # Delete by name (NOT `pm2 delete all`) to spare unrelated apps

Both were executed as command substitutions on the GitHub runner, with the
step's secrets in the environment. Reproduced standalone:

    $ cat << ENDSSH
        # `echo EXECUTED_ON_CLIENT` in a comment
      ENDSSH
    # EXECUTED_ON_CLIENT in a comment

`pm2` is absent from the runner so nothing happened, and `<name>` failed as a
redirection — which is exactly the SC1073 shellcheck was reporting. The comment
also reached the server with its text silently rewritten.

Triage of the rest:

- SC2087 / SC2029 (4) — client-side expansion is deliberate: NEXT_PUBLIC_*,
  BACKEND_NAME/FRONTEND_NAME, ENV_BASE64 and REMOTE_PATH exist only on the
  runner, and the server-side forms are already escaped as \${...}. Quoting the
  delimiter would break the deploy. Disabled inline with that reason.
- SC2086 (29) — quoted. Mostly `>> $GITHUB_STEP_SUMMARY`.
- SC2129 (3) — the three summary blocks became grouped `{ ... } >> "$..."`.
- SC2044 (1) — the hardcoded-URL check looped over `$(find ...)`, so any path
  containing a space split into two non-existent files and every grep below it
  silently missed. Now `while IFS= read -r` with process substitution, which
  also keeps ISSUES accumulating in the current shell.
- SC2002 (1) — useless cat.
- SC2006 (3) — the backticks above; gone with them.

The job now runs `./actionlint` with shellcheck enabled, and first asserts the
binary is on PATH: actionlint SKIPS every shell finding when it is missing,
without warning, so the gate would otherwise go hollow the day the runner image
drops it. That is not theoretical — it is why #1122's local verification was
invalid.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 5m 33s —— View job


✅ Precision bug review: no defects found

Scoped to concrete defects only (logic errors, security, data loss, races, contract breaks, resource leaks). Style/coverage is covered by a separate review.

Verdict: clean. No inline findings to post.

What I verified against the checked-out files (not just the diff):

  • Headline bug class fully cleared. Grepped every backtick in deploy.yml. The only ones left are top-level YAML comments (deploy.yml:43, :73, :364 — never shell-parsed), run-script comments outside heredocs (:309, :591, :596, :726, :740), and escaped \` in echo (:329, :748). No unescaped backtick remains inside any unquoted << ENDSSH body, so the command-substitution-on-runner defect is actually gone, not just silenced.
  • SC2087/SC2029 disables match reality. Spot-checked the staging and production heredocs: the unescaped ${...} (NEXT_PUBLIC_*, BACKEND/FRONTEND_NAME, ENV_BASE64, REMOTE_PATH, RELEASE_TAG_B64) are exactly the runner-env values that must bake in client-side, and the server-side forms are genuinely escaped \${...} (deploy.yml:244-246,265-269,281,631,663-665,688-698). The disables are justified, not masking a real expansion error.
  • SC2044 fix is correct. while IFS= read -r file; do … done < <(find …) (test.yml:218-234) runs in the current shell so ISSUES still accumulates (process substitution, not a pipe — the author's "keeps ISSUES in the current shell" claim holds). Loop body is unchanged; continue and the grep checks behave identically. read's EOF exit terminates the loop and is exempt from bash -e, so no spurious exit on the last line.
  • Gate hardening is sound. command -v shellcheck >/dev/null || { …; exit 1; } (test.yml) is syntactically valid (newline-terminated statements, } on its own line) and ./actionlint -color correctly re-enables shellcheck auto-detection (the -shellcheck= opt-out was the empty-path disable).
  • Grouped redirects { … } >> "$GITHUB_STEP_SUMMARY" are behaviorally equivalent to the per-line appends under -e; quoting the target is strictly safer.
  • SC2002 fix (jq … file instead of cat file | jq) is identical in effect.

One non-blocking note (not a defect — out of my scope, mentioned only because it touches correctness confidence): the two new wiring tests assert only that the strings command -v shellcheck / ::error:: are present in test.yml, so they can't detect a future reordering that runs the guard after ./actionlint. Not flagged as a finding since it's test-coverage, not a runtime defect.

gh pr checks needs approval in this sandbox so I couldn't read the live workflow-lint result; the conclusion above is from reading the files directly. A shellcheck-directive misplacement would surface as a failing workflow-lint job on this PR, not as a silent defect.

@frankbria

Copy link
Copy Markdown
Owner Author

Demo — the AC that CI cannot check, checked

#1130's last acceptance criterion: "Verify a staging deploy still succeeds after any deploy.yml change — this cannot be validated by CI alone."

Ran a real staging deploy from this branchrun 31457587132, Deploy to Staging ✓ in 1m1s, after the full Run Test Suite gate passed. The box resets to origin/main, so the code deployed is main and only the workflow logic is this branch's — which is exactly the thing under test.

Post-deploy state:

$ curl -o /dev/null -w "%{http_code}" https://dev.codeframe.sh/health          → 200
$ curl -o /dev/null -w "%{http_code}" https://dev.codeframe.sh/                → 200
$ curl -o /dev/null -w "%{http_code}" https://dev.codeframe.sh/api/v2/settings/keys → 401  (auth enforcement intact)
$ ssh staging 'pm2 list'
  codeframe-staging-backend    online  2m  0 restarts
  codeframe-staging-frontend   online  2m  0 restarts

Zero restarts — the PM2 cold-start path (the part these quoting changes touch most) worked on the first attempt.

The heredoc bug, caught in a production log

I said in the PR body that the backticked comments were being executed on the runner. Here is the previous deploy of main (run 31456705005), unmodified, in its own log:

Deploy to Staging  /home/runner/work/_temp/….sh: line 1: name: No such file or directory
Deploy to Staging  /home/runner/work/_temp/….sh: line 1: pm2: command not found

That is the runner attempting to run pm2 delete all and to redirect from a file named name — text that exists only inside prose comments in the ssh heredoc. The same grep against this branch's deploy returns nothing. Before/after, on real infrastructure, not a local reproduction.

Everything else

Check Result
actionlint (shellcheck on PATH) 0 findings, was 44
Workflow Lint (actionlint) job in CI ✓ 6s — and now genuinely running shellcheck, since runners have it
Full backend suite 6455 passed, 49 skipped, 600s
uv run ruff check . clean
Staging deploy ✓ (above)

Third-party review

codex review against main: no findings.

The workflow and test changes are focused on enabling shellcheck-backed actionlint and cleaning up shell quoting/heredoc issues. The updated workflow lint wiring is covered by tests, YAML parsing succeeds, and local actionlint reports no issues.

Known limitation

Deploy to Production was skipped, as always — that environment holds no secrets and cannot run (#1143). Its heredoc got the identical treatment, so the same class of bug is fixed there too, but it is fixed unverified by a live run. Nothing in this PR makes that worse: the production job could not run before either.

@frankbria
frankbria merged commit db19c0b into main Aug 11, 2026
25 checks passed
@frankbria
frankbria deleted the fix/1130-shellcheck-workflow-lint branch August 11, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2.34] actionlint's shellcheck findings are suppressed in workflow-lint — 44 pre-existing issues in run: blocks

1 participant