From ed8bfd94fc2bf400633f525884229d0ce5f4a7fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:12:05 +0900 Subject: [PATCH 1/4] fix(ci): lint modern Actions schemas safely --- .../workflows/opencode-review-dispatch.yml | 55 +++- .github/workflows/pr-review-autofix.yml | 5 +- CHANGELOG.md | 6 + ...actionlint-modern-schema-and-shellcheck.md | 62 ++++ scripts/ci/lint_github_workflows.rb | 202 +++++++++++++ tests/test_lint_github_workflows.py | 271 ++++++++++++++++++ ...t_pr_review_autofix_nvidia_nim_contract.py | 2 +- 7 files changed, 595 insertions(+), 8 deletions(-) create mode 100644 docs/doctoring/actionlint-modern-schema-and-shellcheck.md create mode 100644 scripts/ci/lint_github_workflows.rb create mode 100644 tests/test_lint_github_workflows.py diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 3bc1ce6d38..5bb06814cf 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -907,9 +907,11 @@ jobs: } append_command() { - printf '$ ' >>"$summary_file" - printf '%q ' "$@" >>"$summary_file" - printf '\n' >>"$summary_file" + { + printf '$ ' + printf '%q ' "$@" + printf '\n' + } >>"$summary_file" } emit_captured_log() { @@ -1182,6 +1184,8 @@ jobs: --command-json "$configured_command_json" done <<<"$configured_commands_json" else + # The child shell expands its own cwd and PYTHONPATH. + # shellcheck disable=SC2016 run_and_capture "Python coverage with missing-line report (${project_dir})" \ bash -c 'cd "$1" && PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m coverage run -m pytest tests && python3 -m coverage report --show-missing' bash "$project_dir" fi @@ -1189,6 +1193,8 @@ jobs: if [ "$measured_projects" -eq 0 ]; then if has_tracked_files '*.py'; then + # The child shell resolves the checked-out source layout. + # shellcheck disable=SC2016 run_and_capture "Python coverage with missing-line report" \ bash -c 'PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m coverage run -m pytest && python3 -m coverage report --show-missing' elif python3 -I -c 'import pytest_cov' >/dev/null 2>&1; then @@ -1308,6 +1314,8 @@ jobs: while IFS= read -r project_dir; do if [ -f "${project_dir}/tests/test_docstrings.py" ]; then measured_projects=1 + # The child shell expands its own positional cwd. + # shellcheck disable=SC2016 run_and_capture "Python docstring coverage (${project_dir})" \ bash -c 'cd "$1" && PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m pytest tests/test_docstrings.py' bash "$project_dir" fi @@ -1613,6 +1621,8 @@ jobs: if [ -n "$package_name" ] && jq -e '.workspaces // empty' package.json >/dev/null 2>&1; then run_and_capture "Tauri frontendDist build (${package_dir})" npm run build --workspace "$package_name" else + # The child shell expands its own positional cwd. + # shellcheck disable=SC2016 run_and_capture "Tauri frontendDist build (${package_dir})" bash -c 'cd "$1" && npm run build' bash "$package_dir" fi ;; @@ -1620,6 +1630,8 @@ jobs: if [ -n "$package_name" ] && [ -f pnpm-workspace.yaml ]; then run_and_capture "Tauri frontendDist build (${package_dir})" pnpm --filter "$package_name" run build else + # The child shell expands its own positional cwd. + # shellcheck disable=SC2016 run_and_capture "Tauri frontendDist build (${package_dir})" bash -c 'cd "$1" && pnpm run build' bash "$package_dir" fi ;; @@ -1627,6 +1639,8 @@ jobs: if [ -n "$package_name" ] && jq -e '.workspaces // empty' package.json >/dev/null 2>&1; then run_and_capture "Tauri frontendDist build (${package_dir})" yarn workspace "$package_name" build else + # The child shell expands its own positional cwd. + # shellcheck disable=SC2016 run_and_capture "Tauri frontendDist build (${package_dir})" bash -c 'cd "$1" && yarn build' bash "$package_dir" fi ;; @@ -1743,8 +1757,8 @@ jobs: # coverage command still runs and reports any uncovered GPU lines # exactly as before, so Rust repositories without GPU code are # unaffected and no gate is weakened. - if ls /usr/share/vulkan/icd.d/lvp_icd*.json >/dev/null 2>&1; then - lvp_icd="$(ls /usr/share/vulkan/icd.d/lvp_icd*.json | head -n1)" + lvp_icd="$(find /usr/share/vulkan/icd.d -maxdepth 1 -type f -name 'lvp_icd*.json' -print -quit 2>/dev/null || true)" + if [ -n "$lvp_icd" ]; then export VK_ICD_FILENAMES="$lvp_icd" export VK_DRIVER_FILES="$lvp_icd" export WGPU_BACKEND=vulkan @@ -2868,12 +2882,17 @@ jobs: language_signal="Match changed prose" fi + # Markdown backticks are literal; the format argument is intentional. + # shellcheck disable=SC2016 printf -- '- Preferred review language: `%s`\n' "$language_signal" printf -- '- Rule: write human-readable review prose in the preferred language; keep file paths, identifiers, logs, quoted source, error text, and protocol literals unchanged.\n' + # shellcheck disable=SC2016 printf -- '- PR title: `%s`\n' "$(printf '%s' "$title" | tr '\r\n`' ' ' | cut -c 1-240)" if [ -n "$body" ]; then + # shellcheck disable=SC2016 printf -- '- PR body excerpt: `%s`\n' "$(printf '%s' "$body" | tr '\r\n`' ' ' | cut -c 1-360)" else + # shellcheck disable=SC2016 printf -- '- PR body excerpt: `[empty]`\n' fi } @@ -3153,6 +3172,8 @@ jobs: shift if ! git -C "$OPENCODE_SOURCE_WORKDIR" diff "$@"; then + # Markdown backticks are literal; the format arguments are intentional. + # shellcheck disable=SC2016 printf 'Unable to collect %s from `%s` to `%s`; continue review from available changed-file evidence and direct file inspection.\n' "$description" "$PR_MERGE_BASE" "$PR_HEAD_SHA" fi } @@ -3163,12 +3184,14 @@ jobs: printf -- "- Base SHA: \`%s\`\n" "$PR_BASE_SHA" printf -- "- Head SHA: \`%s\`\n\n" "$PR_HEAD_SHA" if ! PR_MERGE_BASE="$(git -C "$OPENCODE_SOURCE_WORKDIR" merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA")"; then + # shellcheck disable=SC2016 printf 'Merge-base discovery failed for `%s` and `%s`; falling back to base SHA for bounded diff evidence.\n\n' "$PR_BASE_SHA" "$PR_HEAD_SHA" PR_MERGE_BASE="$PR_BASE_SHA" fi printf -- "- Merge base SHA: \`%s\`\n\n" "$PR_MERGE_BASE" printf '## Current-head authority order\n\n' printf 'Treat current-head sections in this file as authoritative for this run: Other unresolved review thread evidence, Failed GitHub Check evidence, Coverage execution evidence, Changed files, and Focused changed hunks.\n' + # shellcheck disable=SC2016 printf 'All PR reviews and comments evidence is historical context only and may contain stale bot conclusions. Do not infer active failed checks, unresolved threads, or missing changed files from those comments unless current-head evidence corroborates the same claim for Head SHA `%s`.\n\n' "$PR_HEAD_SHA" if ! git -C "$OPENCODE_SOURCE_WORKDIR" diff --name-only --find-renames "$PR_MERGE_BASE" "$PR_HEAD_SHA" | awk 'NF > 0 && $0 !~ /^\// && $0 !~ /(^|\/)\.\.($|\/)/ { print }' >"$OPENCODE_CHANGED_FILES_FILE"; then @@ -4804,6 +4827,8 @@ jobs: "$@" } + # jq expands its own variables inside this literal program. + # shellcheck disable=SC2016 self_check_filter=' def self_check: (.name // "") as $n @@ -5466,6 +5491,8 @@ jobs: if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { printf '## OpenCode %s review body\n\n' "$event" + # Markdown backticks are literal; the format argument is intentional. + # shellcheck disable=SC2016 printf -- '- Head SHA: `%s`\n' "$HEAD_SHA" printf -- '- Workflow run: %s\n' "$RUN_ID" printf -- '- Workflow attempt: %s\n\n' "$RUN_ATTEMPT" @@ -5812,6 +5839,8 @@ jobs: printf '## Summary\n\n' printf '%s\n\n' "$summary" printf '## Adversarial validation\n\n' + # Markdown fences are literal; the format argument is intentional. + # shellcheck disable=SC2016 printf '```json\n%s\n```\n\n' "$adversarial_evidence" printf -- '- Result: REQUEST_CHANGES\n' printf -- '- Reason: %s\n\n' "$reason" @@ -6609,6 +6638,8 @@ jobs: case "$mode" in failed) + # jq expands its own variables inside this literal program. + # shellcheck disable=SC2016 jq_filter=' [.[].check_runs[]?] | sort_by((.started_at // .completed_at // .created_at // ""), (.id // 0)) @@ -6625,6 +6656,8 @@ jobs: ' ;; pending) + # jq expands its own variables inside this literal program. + # shellcheck disable=SC2016 jq_filter=' [.[].check_runs[]?] | sort_by((.started_at // .completed_at // .created_at // ""), (.id // 0)) @@ -6689,6 +6722,8 @@ jobs: local owner="${GH_REPOSITORY%%/*}" local name="${GH_REPOSITORY#*/}" + # GraphQL variables are expanded by GitHub, not Bash. + # shellcheck disable=SC2016 timeout "$(check_lookup_api_timeout_seconds)s" gh api graphql \ -f owner="$owner" \ -f name="$name" \ @@ -6842,6 +6877,8 @@ jobs: commit_check_runs_file="$(mktemp)" filtered_rollup_file="$(mktemp)" successful_check_names_file="$(mktemp)" + # GraphQL variables are expanded by GitHub, not Bash. + # shellcheck disable=SC2016 if ! pr_node_id="$(timeout "$(check_lookup_api_timeout_seconds)s" gh api graphql \ -f owner="$owner" \ -f name="$name" \ @@ -7285,6 +7322,8 @@ jobs: head_ref="$(printf '%s\n' "$pr_json" | jq -r '.headRefName // empty')" [ -n "$head_ref" ] || return 1 lookup_error_file="$(mktemp)" + # jq expands its own variables inside this literal program. + # shellcheck disable=SC2016 if ! GH_TOKEN="$scan_token" timeout "$(check_lookup_api_timeout_seconds)s" \ gh api -X GET "repos/${GH_REPOSITORY}/code-scanning/alerts" \ -f "ref=refs/heads/${head_ref}" \ @@ -7352,6 +7391,8 @@ jobs: printf 'OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed.\n\n' printf '## Findings\n\n' printf '### 1. HIGH Current-head GitHub Checks - Fix failed required checks before approval\n' + # Markdown backticks are literal; the format argument is intentional. + # shellcheck disable=SC2016 printf -- '- Problem: Failed same-head checks remain for `%s`.\n' "$HEAD_SHA" printf -- '- Root cause: The model-unavailable evidence fallback is allowed only when peer GitHub Checks are complete and clean.\n' printf -- '- Fix: Read and fix the failed check logs below, then rerun the current-head checks.\n' @@ -7408,10 +7449,14 @@ jobs: if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { printf '## OpenCode required check satisfied by existing same-head approval\n\n' + # Markdown backticks are literal in these format strings. + # shellcheck disable=SC2016 printf -- '- Result: `EXISTING_CURRENT_HEAD_APPROVAL`\n' + # shellcheck disable=SC2016 printf -- '- Head SHA: `%s`\n' "$HEAD_SHA" printf -- '- Workflow run: %s\n' "$RUN_ID" printf -- '- Workflow attempt: %s\n' "$RUN_ATTEMPT" + # shellcheck disable=SC2016 printf -- '- Model-pool outcome: `%s`\n' "${OPENCODE_MODEL_POOL_OUTCOME:-unknown}" printf -- '- Reason: a prior real-model OpenCode APPROVED review with passed structured adversarial probes already targets this exact head, and the fallback rechecked coverage, peer checks, code-scanning alerts, and unresolved review threads before accepting it.\n' printf -- '- Review state: unchanged; no duplicate APPROVE review was posted from model-output-unavailable evidence.\n\n' diff --git a/.github/workflows/pr-review-autofix.yml b/.github/workflows/pr-review-autofix.yml index 7863577224..49f4b34aa4 100644 --- a/.github/workflows/pr-review-autofix.yml +++ b/.github/workflows/pr-review-autofix.yml @@ -509,8 +509,9 @@ jobs: if [ "${#changed_python_files[@]}" -gt 0 ]; then python3 -m py_compile "${changed_python_files[@]}" fi - if [ "${#changed_workflows[@]}" -gt 0 ] && command -v actionlint >/dev/null 2>&1; then - actionlint "${changed_workflows[@]}" + if [ "${#changed_workflows[@]}" -gt 0 ]; then + ruby "$GITHUB_WORKSPACE/trusted-autofix-source/scripts/ci/lint_github_workflows.rb" \ + "${changed_workflows[@]}" fi - name: Commit and push autofix diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc40394c9..29ee12a767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Separate actionlint schema/expression/Pyflakes validation from file-based + ShellCheck execution so workflow shell blocks larger than 64 KiB cannot + deadlock the write-capable autofix verifier, preserve actionlint's shell and + expression semantics, and narrowly accept GitHub's native + `concurrency.queue: max` while rejecting every other queue value until + upstream actionlint schema support is released. - Retried the Strix scan up to `STRIX_TRANSIENT_RETRY_PER_MODEL` times, same model, when the log shows the upstream strix-agent Caido sandbox bootstrap timing race (`loginAsGuest failed after N attempts` / `Failed to connect to 127.0.0.1 port `; tracked upstream as usestrix/strix#1036, #1037, #1056). A slow CI runner can exceed strix-agent's fixed 10-attempt sandbox-login budget before its local intercepting proxy is reachable, even though the penetration test itself never started and no vulnerability evidence was produced or lost; the Docker image is already cached from the failed attempt, so a same-model retry is cheap and typically clears the one-off boot race. Not wired into cross-model fallback, since switching LLM models cannot change local sandbox container boot timing. - Replaced nonexistent `job.workflow_repository` / `job.workflow_sha` / `job.workflow_ref` / `job.workflow_file_path` context references (actionlint: "property ... is not defined in object type") in `pr-review-fix-scheduler.yml`'s called-workflow source verification and `exact-artifact-sbom-attestation.yml`'s trusted-verifier checkout. Both always failed closed on the missing properties (ContextualWisdomLab/.github#1212) or, for the SBOM attestation checkout, silently resolved an empty repository/ref instead of the pinned trusted source (downstream `gh attestation verify --signer-repo`/`--signer-workflow`, using the separately hardcoded `SIGNER_REPOSITORY` constant rather than any workflow_ref, still failed closed on the resulting empty signer identity). `github.workflow_ref`/`github.workflow_sha` are real, documented properties, but for a `workflow_call` target they reflect the top-level *calling* workflow, not the reusable workflow's own file — a prefix match against the reusable workflow's own path can never succeed. `exact-artifact-sbom-attestation.yml`'s checkout now uses `github.workflow_sha` (correct today: it has no callers yet); `pr-review-fix-scheduler.yml`'s identity check instead validates `github.repository`, since every current caller uses a local, same-repo `uses: ./...` where caller and callee share one commit and `github.workflow_sha` is still the right pin. Tracked follow-up for the SBOM attestation checkout once a real (potentially cross-repo) caller exists: ContextualWisdomLab/.github#1228. - Used the receiving repository's workflow token for same-repository scheduler diff --git a/docs/doctoring/actionlint-modern-schema-and-shellcheck.md b/docs/doctoring/actionlint-modern-schema-and-shellcheck.md new file mode 100644 index 0000000000..9c25144f2e --- /dev/null +++ b/docs/doctoring/actionlint-modern-schema-and-shellcheck.md @@ -0,0 +1,62 @@ +# Actionlint modern-schema and large-shell compatibility + +Decision date: **2026-08-22** + +## Incident + +The write-capable PR autofix worker validates every workflow it changes with +`actionlint`. Two upstream gaps can make that fail or stall even when GitHub +accepts the workflow. + +1. GitHub Actions supports `queue: max` for concurrency groups, while released + actionlint 1.7.12 still reports that key as invalid. Upstream pull request + 654 tracks schema support. +2. Actionlint can deadlock while sending a workflow `run` block larger than a + pipe buffer to its ShellCheck subprocess. Upstream issue 712 reproduces the + boundary at 64 KiB. The central OpenCode review workflow contains larger + trusted shell blocks, so an autofix touching it can wait indefinitely. + +These are linter transport/schema gaps, not reasons to remove workflow schema +validation or shell analysis. + +## Decision + +Keep actionlint as the schema, expression, and Pyflakes validator, but disable +only its ShellCheck subprocess integration with `-shellcheck=`. The trusted +`lint_github_workflows.rb` boundary uses Ruby's standard-library Psych parser to +read the same YAML scalar values, reproduces actionlint 1.7.12's workflow/job/ +runner/step shell precedence, expression normalization, implicit shell setup, +and narrow rule exclusions, and invokes the installed ShellCheck against unique +regular temporary files. It parses ShellCheck JSON, restores the workflow job +and step identity in every diagnostic, preserves findings as a failing status, +and fails closed on malformed output or a missing executable. + +The autofix worker ignores only actionlint's exact released-schema diagnostic +for the concurrency `queue` key. Before linting, it rejects every changed +workflow whose `queue` value is not exactly `max`; therefore the compatibility +exception cannot admit an invented queue mode. + +This is a temporary compatibility boundary. Remove the queue diagnostic +exception after an actionlint release containing pull request 654 is pinned. +Remove the stdin spool only after issue 712 is fixed and a greater-than-64-KiB +regression passes directly through the pinned actionlint/ShellCheck pair. + +## Verification + +- A greater-than-64-KiB synthetic shell program reaches the delegated + ShellCheck executable through a regular file, without content loss. +- Bash, sh, Windows/PowerShell, Python, workflow defaults, and GitHub expression + normalization retain actionlint's effective-shell behavior. +- ShellCheck findings, malformed result JSON, actionlint failures, and invalid + concurrency queue values all fail closed with actionable workflow context. + +## References + +GitHub. (2026, May 7). *GitHub Actions concurrency groups now allow larger +queues*. https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/ + +Murai, R. (2025). *Support queue: max in concurrency* [Pull request #654]. +GitHub. https://github.com/rhysd/actionlint/pull/654 + +Murai, R. (2026). *Shellcheck integration deadlocks for run blocks greater than +64 KiB* [Issue #712]. GitHub. https://github.com/rhysd/actionlint/issues/712 diff --git a/scripts/ci/lint_github_workflows.rb b/scripts/ci/lint_github_workflows.rb new file mode 100644 index 0000000000..b7e3143c80 --- /dev/null +++ b/scripts/ci/lint_github_workflows.rb @@ -0,0 +1,202 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Run actionlint without its oversized-stdin ShellCheck transport, then apply +# the same ShellCheck policy directly to regular temporary files. This keeps +# schema, expression, Python, and shell validation while avoiding the deadlock +# tracked by rhysd/actionlint#712. + +require "json" +require "open3" +require "tempfile" +require "yaml" + +QUEUE_DIAGNOSTIC = + 'unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"' +SHELLCHECK_EXCLUSIONS = "SC1091,SC2194,SC2050,SC2153,SC2154,SC2157,SC2043" + +class WorkflowLintError < StandardError; end + +def load_workflow(path) + document = YAML.safe_load( + File.read(path, encoding: "UTF-8"), + permitted_classes: [], + permitted_symbols: [], + aliases: true + ) + raise WorkflowLintError, "#{path}: workflow document must be a mapping" unless document.is_a?(Hash) + + document +rescue Psych::Exception, SystemCallError => error + raise WorkflowLintError, "#{path}: could not read workflow YAML: #{error.message}" +end + +def validate_concurrency_queue!(path, label, concurrency) + return unless concurrency.is_a?(Hash) && concurrency.key?("queue") + return if concurrency["queue"] == "max" + + raise WorkflowLintError, + "#{path}: #{label} concurrency queue must be exactly max, got #{concurrency['queue'].inspect}" +end + +def validate_queue_contract!(path, workflow) + validate_concurrency_queue!(path, "workflow", workflow["concurrency"]) + jobs = workflow["jobs"] + return unless jobs.is_a?(Hash) + + jobs.each do |job_name, job| + next unless job.is_a?(Hash) + + validate_concurrency_queue!(path, "job #{job_name}", job["concurrency"]) + end +end + +def windows_runner?(job) + Array(job["runs-on"]).any? do |label| + normalized = label.to_s.downcase + normalized == "windows" || normalized.start_with?("windows-") + end +end + +def effective_shell(workflow, job, step) + step["shell"] || + job.dig("defaults", "run", "shell") || + workflow.dig("defaults", "run", "shell") || + (windows_runner?(job) ? "pwsh" : "bash") +end + +def shellcheck_dialect(shell) + return shell if ["bash", "sh"].include?(shell) + return "bash" if shell.start_with?("bash ") + return "sh" if shell.start_with?("sh ") + + nil +end + +def sanitize_expressions(script) + sanitized = script.dup + offset = 0 + while (start_index = sanitized.index("${{", offset)) + end_index = sanitized.index("}}", start_index) + break unless end_index + + length = end_index + 2 - start_index + sanitized[start_index, length] = "_" * length + offset = start_index + length + end + sanitized +end + +def shell_scripts(path, workflow) + jobs = workflow["jobs"] + return enum_for(__method__, path, workflow) unless block_given? + return unless jobs.is_a?(Hash) + + jobs.each do |job_name, job| + next unless job.is_a?(Hash) && job["steps"].is_a?(Array) + + job["steps"].each_with_index do |step, index| + next unless step.is_a?(Hash) && step["run"].is_a?(String) + + dialect = shellcheck_dialect(effective_shell(workflow, job, step).to_s) + next unless dialect + + step_name = step["name"].to_s.strip + step_name = (index + 1).to_s if step_name.empty? + yield path, job_name.to_s, step_name, dialect, step["run"] + end + end +end + +def run_actionlint(paths) + executable = ENV.fetch("ACTIONLINT", "actionlint") + arguments = [ + "-shellcheck=", + "-ignore", + QUEUE_DIAGNOSTIC, + *paths + ] + stdout, stderr, status = Open3.capture3(executable, *arguments) + return 0 if status.success? + + warn stdout unless stdout.empty? + warn stderr unless stderr.empty? + status.exitstatus || 2 +rescue SystemCallError => error + raise WorkflowLintError, "actionlint could not start: #{error.message}" +end + +def run_shellcheck(path, job_name, step_name, dialect, script) + setup = dialect == "bash" ? "set -eo pipefail" : "set -e" + source = "#{setup}\n#{sanitize_expressions(script)}\n" + executable = ENV.fetch("SHELLCHECK", "shellcheck") + stdout = stderr = nil + status = nil + + Tempfile.create(["actionlint-shellcheck-", ".#{dialect}"]) do |file| + file.chmod(0o600) + file.write(source) + file.flush + stdout, stderr, status = Open3.capture3( + executable, + "--norc", + "-f", + "json", + "-x", + "--shell", + dialect, + "-e", + SHELLCHECK_EXCLUSIONS, + file.path + ) + end + + unless [0, 1].include?(status.exitstatus) + detail = stderr.to_s.strip + detail = "exit #{status.exitstatus}" if detail.empty? + raise WorkflowLintError, "#{path}: ShellCheck failed for job=#{job_name} step=#{step_name}: #{detail}" + end + + findings = JSON.parse(stdout) + raise JSON::ParserError, "top-level result is not an array" unless findings.is_a?(Array) + + findings.each do |finding| + script_line = [finding.fetch("line").to_i - 1, 1].max + message = finding.fetch("message").to_s.delete_suffix(".") + warn( + "#{path}: shellcheck reported issue in job=#{job_name} step=#{step_name}: " \ + "SC#{finding.fetch('code')}:#{finding.fetch('level')}:#{script_line}:" \ + "#{finding.fetch('column')}: #{message}" + ) + end + findings.length +rescue JSON::ParserError, KeyError => error + raise WorkflowLintError, + "#{path}: invalid ShellCheck JSON for job=#{job_name} step=#{step_name}: #{error.message}" +rescue SystemCallError => error + raise WorkflowLintError, "ShellCheck could not start: #{error.message}" +end + +def lint(paths) + raise WorkflowLintError, "usage: lint_github_workflows.rb WORKFLOW..." if paths.empty? + + workflows = paths.to_h do |path| + workflow = load_workflow(path) + validate_queue_contract!(path, workflow) + [path, workflow] + end + actionlint_status = run_actionlint(paths) + return actionlint_status unless actionlint_status.zero? + + findings = workflows.sum do |path, workflow| + shell_scripts(path, workflow).sum do |script_path, job_name, step_name, dialect, script| + run_shellcheck(script_path, job_name, step_name, dialect, script) + end + end + findings.zero? ? 0 : 1 +rescue WorkflowLintError => error + warn "ERROR: #{error.message}" + 2 +end + +exit lint(ARGV) diff --git a/tests/test_lint_github_workflows.py b/tests/test_lint_github_workflows.py new file mode 100644 index 0000000000..70e45b56eb --- /dev/null +++ b/tests/test_lint_github_workflows.py @@ -0,0 +1,271 @@ +"""Behavioral regressions for bounded actionlint and ShellCheck execution.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import textwrap + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +LINTER = ROOT / "scripts" / "ci" / "lint_github_workflows.rb" +AUTOFIX_WORKFLOW = ROOT / ".github" / "workflows" / "pr-review-autofix.yml" + + +def _write_executable(path: Path, source: str) -> None: + """Write one executable test transport with deterministic behavior.""" + + path.write_text(textwrap.dedent(source).lstrip(), encoding="utf-8") + path.chmod(0o755) + + +def _tool_environment(tmp_path: Path) -> tuple[dict[str, str], Path]: + """Return isolated fake lint executables and their capture directory.""" + + binary_dir = tmp_path / "bin" + capture_dir = tmp_path / "captures" + binary_dir.mkdir() + capture_dir.mkdir() + _write_executable( + binary_dir / "actionlint", + """ + #!/usr/bin/env python3 + import json + import os + from pathlib import Path + import sys + + capture = Path(os.environ["LINT_CAPTURE_DIR"]) / "actionlint.json" + capture.write_text(json.dumps(sys.argv[1:]), encoding="utf-8") + print(os.environ.get("ACTIONLINT_OUTPUT", ""), end="") + raise SystemExit(int(os.environ.get("ACTIONLINT_STATUS", "0"))) + """, + ) + _write_executable( + binary_dir / "shellcheck", + """ + #!/usr/bin/env python3 + import json + import os + from pathlib import Path + import sys + + root = Path(os.environ["LINT_CAPTURE_DIR"]) + index = len(list(root.glob("shellcheck-*.json"))) + script = Path(sys.argv[-1]).read_text(encoding="utf-8") + (root / f"shellcheck-{index}.json").write_text( + json.dumps({"args": sys.argv[1:], "script": script}), + encoding="utf-8", + ) + if "FINDING_MARKER" in script: + print(json.dumps([{ + "line": 3, + "column": 7, + "level": "warning", + "code": 2086, + "message": "Double quote to prevent globbing.", + }])) + raise SystemExit(1) + if os.environ.get("SHELLCHECK_MALFORMED") == "1": + print("not-json") + raise SystemExit(0) + print("[]") + """, + ) + environment = { + **os.environ, + "PATH": f"{binary_dir}{os.pathsep}{os.environ['PATH']}", + "ACTIONLINT": str(binary_dir / "actionlint"), + "SHELLCHECK": str(binary_dir / "shellcheck"), + "LINT_CAPTURE_DIR": str(capture_dir), + } + return environment, capture_dir + + +def _run_linter(workflow: Path, environment: dict[str, str]) -> subprocess.CompletedProcess[str]: + """Run the real trusted linter against one controlled workflow.""" + + return subprocess.run( + ["ruby", str(LINTER), str(workflow)], + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +def test_linter_uses_actionlint_schema_and_file_based_shellcheck(tmp_path: Path) -> None: + """Large Bash and explicit sh scripts use files while other shells stay excluded.""" + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "large.yml" + large_body = "\n".join(" # bounded filler" for _ in range(4_000)) + workflow.write_text( + "\n".join( + ( + "name: large-shell-boundary", + "on: push", + "defaults:", + " run:", + " shell: bash", + "concurrency:", + " group: exact", + " queue: max", + "jobs:", + " linux:", + " runs-on: ubuntu-24.04", + " steps:", + " - name: Large Bash", + " run: |", + ' echo "${{ github.sha }}"', + large_body, + " - name: Explicit sh", + " shell: sh", + " run: echo ok", + " - name: Python", + " shell: python", + " run: print('ok')", + " windows:", + " runs-on: windows-2025", + " steps:", + " - shell: pwsh", + " run: Write-Host ok", + "", + ) + ), + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + actionlint_args = json.loads( + (capture_dir / "actionlint.json").read_text(encoding="utf-8") + ) + shellcheck_records = [ + json.loads(path.read_text(encoding="utf-8")) + for path in sorted(capture_dir.glob("shellcheck-*.json")) + ] + assert result.returncode == 0, result.stderr + assert actionlint_args[0] == "-shellcheck=" + assert actionlint_args[-1] == str(workflow) + assert len(shellcheck_records) == 2 + assert shellcheck_records[0]["args"][:7] == [ + "--norc", + "-f", + "json", + "-x", + "--shell", + "bash", + "-e", + ] + assert shellcheck_records[0]["args"][-1] != "-" + assert shellcheck_records[0]["script"].startswith( + 'set -eo pipefail\necho "_________________"\n' + ) + assert len(shellcheck_records[0]["script"].encode()) > 65_536 + assert shellcheck_records[1]["args"][5] == "sh" + assert shellcheck_records[1]["script"] == "set -e\necho ok\n" + + +def test_write_capable_autofix_always_uses_the_trusted_linter() -> None: + """Changed workflows fail closed through the dispatch-pinned helper.""" + + workflow = AUTOFIX_WORKFLOW.read_text(encoding="utf-8") + invocation = ( + 'ruby "$GITHUB_WORKSPACE/trusted-autofix-source/scripts/ci/' + 'lint_github_workflows.rb"' + ) + + assert invocation in workflow + assert "command -v actionlint" not in workflow + + +def test_linter_reports_shellcheck_findings_with_workflow_context(tmp_path: Path) -> None: + """A delegated finding remains actionable without exposing a temporary filename.""" + + environment, _capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "finding.yml" + workflow.write_text( + """name: finding +on: push +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - name: Unsafe expansion + run: | + echo FINDING_MARKER +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + assert result.returncode == 1 + assert str(workflow) in result.stderr + assert "job=verify" in result.stderr + assert "step=Unsafe expansion" in result.stderr + assert "SC2086:warning:2:7" in result.stderr + assert "Double quote to prevent globbing" in result.stderr + + +def test_linter_rejects_unsupported_queue_before_actionlint(tmp_path: Path) -> None: + """The temporary actionlint exception cannot admit an invented queue value.""" + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "bad-queue.yml" + workflow.write_text( + """name: bad-queue +on: push +concurrency: + group: exact + queue: newest +jobs: {} +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + assert result.returncode == 2 + assert "queue must be exactly max" in result.stderr + assert not (capture_dir / "actionlint.json").exists() + + +@pytest.mark.parametrize( + ("environment_update", "expected"), + ( + ({"ACTIONLINT_STATUS": "3", "ACTIONLINT_OUTPUT": "schema failure\n"}, "schema failure"), + ({"SHELLCHECK_MALFORMED": "1"}, "invalid ShellCheck JSON"), + ), +) +def test_linter_fails_closed_on_tool_failures( + tmp_path: Path, + environment_update: dict[str, str], + expected: str, +) -> None: + """Schema-process and result-integrity failures never become clean evidence.""" + + environment, _capture_dir = _tool_environment(tmp_path) + environment.update(environment_update) + workflow = tmp_path / "tool-failure.yml" + workflow.write_text( + """name: tool-failure +on: push +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - run: echo ok +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + assert result.returncode != 0 + assert expected in result.stderr diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index d2d87b9e38..f58dfeaf79 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "3bc1ce6d385bce569e7a7ba037f149a8f18039d4" +REVIEW_DISPATCH_BLOB_SHA = "5bb06814cf01c745e9ee0f5379a86504f9864b5d" def _workflow_text(path: Path) -> str: From 6e7eb393b309c3cf94a38325dd140df5ed5a88b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:41:39 +0900 Subject: [PATCH 2/4] fix(ci): preserve multiline workflow diagnostics --- .../exact-artifact-sbom-attestation.yml | 26 ++++++++------- scripts/ci/lint_github_workflows.rb | 2 +- tests/test_lint_github_workflows.py | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml index ea9aa4ed08..fb18ed92e6 100644 --- a/.github/workflows/exact-artifact-sbom-attestation.yml +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -313,13 +313,13 @@ jobs: EOF { printf '\n## Exact signed identity\n\n' - printf -- '- Source repository: `%s`\n' "$SOURCE_REPOSITORY" - printf -- '- Source SHA: `%s`\n' "$SOURCE_SHA" - printf -- '- Signer repository: `%s`\n' "$SIGNER_REPOSITORY" - printf -- '- Signer workflow: `%s`\n' "$signer_workflow" - printf -- '- Predicate type: `%s`\n' "$PREDICATE_TYPE" - printf -- '- Wheel: `%s`\n' "$WHEEL_FILENAME" - printf -- '- Source distribution: `%s`\n' "$SDIST_FILENAME" + printf -- "- Source repository: \`%s\`\n" "$SOURCE_REPOSITORY" + printf -- "- Source SHA: \`%s\`\n" "$SOURCE_SHA" + printf -- "- Signer repository: \`%s\`\n" "$SIGNER_REPOSITORY" + printf -- "- Signer workflow: \`%s\`\n" "$signer_workflow" + printf -- "- Predicate type: \`%s\`\n" "$PREDICATE_TYPE" + printf -- "- Wheel: \`%s\`\n" "$WHEEL_FILENAME" + printf -- "- Source distribution: \`%s\`\n" "$SDIST_FILENAME" cat <> offline-attestation-evidence/README.md ( cd offline-attestation-evidence - LC_ALL=C find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' \ - | LC_ALL=C sort \ - | while IFS= read -r evidence_file; do - sha256sum "$evidence_file" - done > SHA256SUMS + mapfile -t evidence_files < <( + LC_ALL=C find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' \ + | LC_ALL=C sort + ) + for evidence_file in "${evidence_files[@]}"; do + sha256sum "$evidence_file" + done > SHA256SUMS ) chmod 0444 \ offline-attestation-evidence/README.md \ diff --git a/scripts/ci/lint_github_workflows.rb b/scripts/ci/lint_github_workflows.rb index b7e3143c80..620c419980 100644 --- a/scripts/ci/lint_github_workflows.rb +++ b/scripts/ci/lint_github_workflows.rb @@ -81,7 +81,7 @@ def sanitize_expressions(script) break unless end_index length = end_index + 2 - start_index - sanitized[start_index, length] = "_" * length + sanitized[start_index, length] = sanitized[start_index, length].gsub(/[^\r\n]/, "_") offset = start_index + length end sanitized diff --git a/tests/test_lint_github_workflows.py b/tests/test_lint_github_workflows.py index 70e45b56eb..fcb5efd5c4 100644 --- a/tests/test_lint_github_workflows.py +++ b/tests/test_lint_github_workflows.py @@ -171,6 +171,39 @@ def test_linter_uses_actionlint_schema_and_file_based_shellcheck(tmp_path: Path) assert shellcheck_records[1]["script"] == "set -e\necho ok\n" +def test_linter_preserves_lines_inside_multiline_expressions(tmp_path: Path) -> None: + """Expression sanitizing keeps ShellCheck source and diagnostic lines aligned.""" + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "multiline-expression.yml" + workflow.write_text( + """name: multiline-expression +on: push +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - run: | + echo "${{ + github.sha + }}" + echo after +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + record = json.loads( + (capture_dir / "shellcheck-0.json").read_text(encoding="utf-8") + ) + lines = record["script"].splitlines() + assert result.returncode == 0, result.stderr + assert lines[2].strip(" _") == "" + assert lines[3].endswith('"') + assert lines[4] == "echo after" + + def test_write_capable_autofix_always_uses_the_trusted_linter() -> None: """Changed workflows fail closed through the dispatch-pinned helper.""" From b421f46411c8440f75fec545a0f65db889cea8f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 16:49:54 +0900 Subject: [PATCH 3/4] test(ci): bound Ruby runtime coverage --- docs/doctoring/actionlint-modern-schema-and-shellcheck.md | 4 ++++ tests/test_lint_github_workflows.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/docs/doctoring/actionlint-modern-schema-and-shellcheck.md b/docs/doctoring/actionlint-modern-schema-and-shellcheck.md index 9c25144f2e..af5b255f18 100644 --- a/docs/doctoring/actionlint-modern-schema-and-shellcheck.md +++ b/docs/doctoring/actionlint-modern-schema-and-shellcheck.md @@ -49,6 +49,10 @@ regression passes directly through the pinned actionlint/ShellCheck pair. normalization retain actionlint's effective-shell behavior. - ShellCheck findings, malformed result JSON, actionlint failures, and invalid concurrency queue values all fail closed with actionable workflow context. +- The offline Python-only coverage sandbox records the Ruby subprocess + contracts as unavailable instead of failing with `FileNotFoundError`; the + hosted quality job, whose runner includes Ruby, executes those contracts and + the real all-workflow lint command. ## References diff --git a/tests/test_lint_github_workflows.py b/tests/test_lint_github_workflows.py index fcb5efd5c4..b104d5cc5f 100644 --- a/tests/test_lint_github_workflows.py +++ b/tests/test_lint_github_workflows.py @@ -5,6 +5,7 @@ import json import os from pathlib import Path +import shutil import subprocess import textwrap @@ -89,6 +90,8 @@ def _tool_environment(tmp_path: Path) -> tuple[dict[str, str], Path]: def _run_linter(workflow: Path, environment: dict[str, str]) -> subprocess.CompletedProcess[str]: """Run the real trusted linter against one controlled workflow.""" + if shutil.which("ruby", path=environment["PATH"]) is None: + pytest.skip("Ruby is unavailable; the hosted quality job runs this runtime contract") return subprocess.run( ["ruby", str(LINTER), str(workflow)], env=environment, From 46f3e72110aadda14d77ae6fd56db756350c3fcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 18:19:41 +0900 Subject: [PATCH 4/4] fix(ci): preserve deterministic workflow evidence --- .../workflows/exact-artifact-sbom-attestation.yml | 9 +++++---- .github/workflows/opencode-review-dispatch.yml | 8 +++++++- ...test_exact_artifact_sbom_attestation_contract.py | 3 +++ ...est_opencode_rust_coverage_toolchain_contract.py | 13 +++++++++++++ tests/test_pr_review_autofix_nvidia_nim_contract.py | 2 +- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml index fb18ed92e6..13edb2ca66 100644 --- a/.github/workflows/exact-artifact-sbom-attestation.yml +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -361,10 +361,11 @@ jobs: } >> offline-attestation-evidence/README.md ( cd offline-attestation-evidence - mapfile -t evidence_files < <( - LC_ALL=C find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' \ - | LC_ALL=C sort - ) + evidence_file_list="$(mktemp "${RUNNER_TEMP}/offline-attestation-files.XXXXXX")" + LC_ALL=C find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' \ + | LC_ALL=C sort > "$evidence_file_list" + mapfile -t evidence_files < "$evidence_file_list" + rm -f "$evidence_file_list" for evidence_file in "${evidence_files[@]}"; do sha256sum "$evidence_file" done > SHA256SUMS diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 9827f5c1ba..921c4a9b25 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -1764,7 +1764,13 @@ jobs: # coverage command still runs and reports any uncovered GPU lines # exactly as before, so Rust repositories without GPU code are # unaffected and no gate is weakened. - lvp_icd="$(find /usr/share/vulkan/icd.d -maxdepth 1 -type f -name 'lvp_icd*.json' -print -quit 2>/dev/null || true)" + lvp_icd="" + for candidate in /usr/share/vulkan/icd.d/lvp_icd*.json; do + if [ -f "$candidate" ]; then + lvp_icd="$candidate" + break + fi + done if [ -n "$lvp_icd" ]; then export VK_ICD_FILENAMES="$lvp_icd" export VK_DRIVER_FILES="$lvp_icd" diff --git a/tests/test_exact_artifact_sbom_attestation_contract.py b/tests/test_exact_artifact_sbom_attestation_contract.py index 511f5cd24e..4b3574bbeb 100644 --- a/tests/test_exact_artifact_sbom_attestation_contract.py +++ b/tests/test_exact_artifact_sbom_attestation_contract.py @@ -247,6 +247,9 @@ def test_workflow_attests_each_exact_distribution_and_exports_offline_evidence() assert "offline-attestation-evidence/README.md" in signer assert "offline-attestation-evidence/SHA256SUMS" in signer assert "sha256sum" in signer + assert 'mapfile -t evidence_files < "$evidence_file_list"' in signer + assert 'LC_ALL=C sort > "$evidence_file_list"' in signer + assert "mapfile -t evidence_files < <(" not in signer def test_quality_workflow_pins_supported_runner_images() -> None: diff --git a/tests/test_opencode_rust_coverage_toolchain_contract.py b/tests/test_opencode_rust_coverage_toolchain_contract.py index b1fd4a124e..ea80a2f68c 100644 --- a/tests/test_opencode_rust_coverage_toolchain_contract.py +++ b/tests/test_opencode_rust_coverage_toolchain_contract.py @@ -67,6 +67,19 @@ def test_trusted_coverage_image_provisions_verified_llvm_19_tools() -> None: assert 'RUN test -x "$LLVM_PROFDATA"\n' in dispatch +def test_software_vulkan_adapter_uses_stable_glob_order() -> None: + """Select the first matching lavapipe adapter in stable pathname order.""" + + dispatch = _dispatch_text() + adapter = dispatch.split("ensure_rust_gpu_adapter() {", 1)[1].split( + "\n }", 1 + )[0] + assert "for candidate in /usr/share/vulkan/icd.d/lvp_icd*.json; do" in adapter + assert 'if [ -f "$candidate" ]; then' in adapter + assert 'lvp_icd="$candidate"' in adapter + assert "-print -quit" not in adapter + + def test_isolated_runtime_receives_reviewed_llvm_constants() -> None: """Require exact LLVM 19 path constants at the Docker sandbox boundary.""" diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 2b53290294..55a27a276f 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "9827f5c1baf375128b784ef6070fef94da13e5a0" +REVIEW_DISPATCH_BLOB_SHA = "921c4a9b250912ec3f51516bbe7189ddd1034ed7" def _workflow_text(path: Path) -> str: