diff --git a/.github/workflows/pr-review-autofix.yml b/.github/workflows/pr-review-autofix.yml index 1b7849a0c5..0aacffa200 100644 --- a/.github/workflows/pr-review-autofix.yml +++ b/.github/workflows/pr-review-autofix.yml @@ -551,8 +551,27 @@ 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 + actionlint_archive="${RUNNER_TEMP}/actionlint.tar.gz" + actionlint_path="${RUNNER_TEMP}/actionlint" + shfmt_path="${RUNNER_TEMP}/shfmt" + curl -fsSL \ + -o "$actionlint_archive" \ + https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz + printf '%s %s\n' \ + '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8' \ + "$actionlint_archive" | sha256sum -c - + tar -xzf "$actionlint_archive" -C "$RUNNER_TEMP" actionlint + curl -fsSL \ + -o "$shfmt_path" \ + https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_linux_amd64 + printf '%s %s\n' \ + 'fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1' \ + "$shfmt_path" | sha256sum -c - + chmod 0755 "$actionlint_path" "$shfmt_path" + PATH="${RUNNER_TEMP}:${PATH}" \ + 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 bf192f6a9e..393db8708e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### Trusted autofix workflow validation and max-queue cancellation + +- The write-capable autofix worker now provisions checksum-pinned actionlint 1.7.12 and BSD-licensed shfmt 3.13.1 whenever it changes a workflow, then invokes the trusted repository linter unconditionally. The compatibility gate accepts `queue: max` only when `cancel-in-progress` is absent or literal YAML `false`; expressions and non-boolean values fail closed because an offline validator cannot prove that they preserve every queued writer intent. This carries the still-valid bounded workflow-linting delta from #1231 without importing its unrelated, conflicted changes. + ### Failed-check finding names the Strix sandbox instead of the gateway - `opencode-review-dispatch.yml`'s `emit_strix_provider_failure_finding` rendered one fixed finding for every `STRIX_PROVIDER_UNAVAILABLE` line, whose Root cause read "The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run". `#1953` had just given the Strix sandbox bootstrap failure its own second verdict token (`STRIX_SANDBOX_UNAVAILABLE`) precisely because that attribution is wrong for it -- the sandbox container never reaches its Caido proxy, so the run dies before the gateway serves anything -- and this consumer re-applied the wrong attribution one step downstream, into the review findings and the failure census. The emitter now branches on the second token: a sandbox verdict gets a finding that names Strix's sandbox, says the verdict does not name the gateway, and tells the reader not to change gateway or provider configuration on its strength. A `STRIX_PROVIDER_UNAVAILABLE` line without the token keeps its existing text verbatim, so the gateway class has no regression surface. No test covered this finding text at all before (`gateway or its discovered provider pool` matched nothing under `tests/`); `tests/test_opencode_dispatch_strix_sandbox_finding.py` now runs the production emitter from the published run block and pins both directions plus the no-signal case. Refs #1953, #1935. 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..3abfe63a1f --- /dev/null +++ b/docs/doctoring/actionlint-modern-schema-and-shellcheck.md @@ -0,0 +1,112 @@ +# Actionlint modern-schema and permissive shell-parser 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 +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, and implicit shell +setup. It streams each Bash or POSIX sh program into shfmt 3.13.1's syntax-tree +JSON mode and fails closed on syntax errors, malformed JSON, or a missing +executable. + +shfmt is BSD-3-Clause, which satisfies the binding commercial/permissive +license policy; the newly introduced direct GPL-3.0-or-later ShellCheck +dependency has been removed. The write-capable worker downloads the official +Linux amd64 actionlint 1.7.12 archive and shfmt 3.13.1 binary only when a +workflow changed, verifies both published SHA-256 digests, extracts only the +actionlint executable, and exposes only those verified executables through the +step-local `PATH`. This avoids relying on an undocumented runner-image tool +inventory while preserving fail-closed schema validation. Behavioral tests use +an isolated temporary `PATH` to prove the same fixed executable and argv +boundary. + +[Required Semgrep run 32637664667](https://github.com/ContextualWisdomLab/.github/actions/runs/32637664667) +still classified the fixed `actionlint` invocation as dynamic because workflow +paths remain argv values. Ruby's `Open3.capture3` passes these separate +arguments directly to the literal executable and does not invoke a shell. The +single inline Semgrep suppression therefore applies only to that reviewed +false positive; the executable-name regression, isolated `PATH` execution, and +fail-closed actionlint status handling remain mandatory. shfmt uses only +literal command arguments and receives the governed shell source through +standard input, so it needs no scanner suppression. + +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. GitHub permits `queue: max` only +when `cancel-in-progress` is false or absent, so a statically true cancellation +setting is also rejected at both workflow and job scope before actionlint runs. + +This is a temporary compatibility boundary. Remove the queue diagnostic +exception after an actionlint release containing pull request 654 is pinned. +Remove the shfmt parser boundary only after issue 712 is fixed, actionlint ships +the corrected transport, its effective shell dependency satisfies the binding +license policy, and a greater-than-64-KiB regression passes through that +replacement. + +## Verification + +- A greater-than-64-KiB synthetic shell program reaches the delegated shfmt + parser through the bounded Ruby subprocess transport without content loss. +- Bash, sh, Windows/PowerShell, Python, workflow defaults, and GitHub expression + normalization retain actionlint's effective-shell behavior. +- shfmt syntax failures, malformed result JSON, actionlint failures, invalid + concurrency queue values, and `queue: max` plus static cancellation 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. The write-capable runtime does not assume + that actionlint is preinstalled: its exact release archive is checksum-pinned + beside shfmt before the linter starts. + +## Queue cancellation proof boundary + +For `queue: max`, `cancel-in-progress` must be absent or literal YAML `false`. +Expression-valued and non-boolean cancellation settings are rejected before +actionlint runs, including the syntactically false expression `${{ false }}`, +because the offline gate cannot prove GitHub's runtime evaluation or that every +queued writer intent remains lossless. This deliberately narrow rule preserves +the expanded queue without turning a dynamic cancellation policy into admission +authority. + +## 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/ + +Martí, D. (2026, April 6). *shfmt v3.13.1* [Computer software]. GitHub. +https://github.com/mvdan/sh/releases/tag/v3.13.1 + +Martí, D. (n.d.). *mvdan/sh license* [BSD 3-Clause license]. GitHub. Retrieved +August 23, 2026, from https://github.com/mvdan/sh/blob/master/LICENSE + +Murai, R. (2025). *Support queue: max in concurrency* [Pull request #654]. +GitHub. https://github.com/rhysd/actionlint/pull/654 + +Murai, R. (2026, March 30). *actionlint v1.7.12* [Computer software]. GitHub. +https://github.com/rhysd/actionlint/releases/tag/v1.7.12 + +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/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..dd8246e00e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3353,3 +3353,11 @@ queries the check-runs API at its own time, order-independently. The implementin their change was safe because they had scoped it narrowly, not because they had checked for the name collision — which is the more useful lesson: **a job name is unique only within one workflow file, and the same name in another file can carry the opposite safety property.** + +### Trusted autofix workflow validation and max-queue cancellation + +- **Status:** Proposed — exact-head hosted verification required before integration. +- **Canonical owner:** ContextualWisdomLab/.github. +- **Gap:** The write-capable autofix worker treated actionlint as optional and the historical queue compatibility gate accepted dynamic or non-boolean `cancel-in-progress` beside `queue: max`, so a missing runner tool could skip validation and an unprovable cancellation policy could discard queued writer intent. +- **Action:** Provision checksum-pinned actionlint 1.7.12 and shfmt 3.13.1 for every changed workflow; accept max queue only with absent or literal YAML `false` cancellation; keep expressions fail-closed. +- **Evidence:** Historical #1231 preserves the original bounded linter delta; RED commit `98768d6b2a27631602ec2405d3d71a8f6d13d534` adds expression/non-boolean regressions. The successor remains Proposed until its GREEN exact head completes current-head checks. diff --git a/scripts/ci/lint_github_workflows.rb b/scripts/ci/lint_github_workflows.rb new file mode 100755 index 0000000000..325f3a3821 --- /dev/null +++ b/scripts/ci/lint_github_workflows.rb @@ -0,0 +1,189 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Run actionlint without its oversized-stdin ShellCheck transport, then parse +# shell steps with the permissively licensed shfmt parser. This keeps schema, +# expression, Python, and shell-syntax validation without the transport +# deadlock tracked by rhysd/actionlint#712 or a GPL tool dependency. + +require "json" +require "open3" +require "yaml" + +QUEUE_DIAGNOSTIC = + 'unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"' +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") + unless concurrency["queue"] == "max" + raise WorkflowLintError, + "#{path}: #{label} concurrency queue must be exactly max, got #{concurrency['queue'].inspect}" + end + cancellation = concurrency["cancel-in-progress"] + return if cancellation.nil? || cancellation == false + + raise WorkflowLintError, + "#{path}: #{label} concurrency queue max requires cancel-in-progress to be literal false or absent" +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 containerized_job?(job) + !job["container"].nil? +end + +def effective_shell(workflow, job, step) + step["shell"] || + job.dig("defaults", "run", "shell") || + workflow.dig("defaults", "run", "shell") || + (windows_runner?(job) ? "pwsh" : (containerized_job?(job) ? "sh" : "bash")) +end + +def shellcheck_dialect(shell) + # A custom shell template names its executable as the first + # whitespace-delimited token (optionally followed by flags and a `{0}` + # script-path placeholder), and GitHub Actions accepts it as an absolute + # path (e.g. "/bin/bash --noprofile --norc -eo pipefail {0}" or + # "/usr/bin/sh {0}"), not just a bare "bash"/"sh" name. Resolve to the + # executable's basename so both forms classify identically. + executable = shell.to_s.split(" ", 2).first.to_s + name = File.basename(executable) + return name if ["bash", "sh"].include?(name) + + 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] = sanitized[start_index, length].gsub(/[^\r\n]/, "_") + 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) + arguments = [ + "-shellcheck=", + "-ignore", + QUEUE_DIAGNOSTIC, + *paths + ] + stdout, stderr, status = Open3.capture3("actionlint", *arguments) # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec + 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_shfmt(path, job_name, step_name, dialect, script) + setup = dialect == "bash" ? "set -eo pipefail" : "set -e" + source = "#{setup}\n#{sanitize_expressions(script)}\n" + stdout, stderr, status = if dialect == "bash" + Open3.capture3("shfmt", "-ln", "bash", "-tojson", stdin_data: source) + else + Open3.capture3("shfmt", "-ln", "posix", "-tojson", stdin_data: source) + end + + unless status.success? + detail = stderr.to_s.strip + detail = "exit #{status.exitstatus}" if detail.empty? + raise WorkflowLintError, + "#{path}: shfmt could not parse job=#{job_name} step=#{step_name}: #{detail}" + end + + syntax_tree = JSON.parse(stdout) + raise JSON::ParserError, "top-level result is not an object" unless syntax_tree.is_a?(Hash) + + 0 +rescue JSON::ParserError => error + raise WorkflowLintError, + "#{path}: invalid shfmt JSON for job=#{job_name} step=#{step_name}: #{error.message}" +rescue SystemCallError => error + raise WorkflowLintError, "shfmt 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? + + workflows.each do |path, workflow| + shell_scripts(path, workflow).each do |script_path, job_name, step_name, dialect, script| + run_shfmt(script_path, job_name, step_name, dialect, script) + end + end + 0 +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..0d2812f4e1 --- /dev/null +++ b/tests/test_lint_github_workflows.py @@ -0,0 +1,492 @@ +"""Behavioral regressions for bounded actionlint and shfmt execution.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +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 / "shfmt", + """ + #!/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("shfmt-*.json"))) + script = sys.stdin.read() + (root / f"shfmt-{index}.json").write_text( + json.dumps({"args": sys.argv[1:], "script": script}), + encoding="utf-8", + ) + if "SYNTAX_ERROR_MARKER" in script: + print("standard input:2:7: expected command", file=sys.stderr) + raise SystemExit(3) + if os.environ.get("SHFMT_MALFORMED") == "1": + print("not-json") + raise SystemExit(0) + print("{}") + """, + ) + environment = { + **os.environ, + "PATH": f"{binary_dir}{os.pathsep}{os.environ['PATH']}", + "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.""" + + 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, + capture_output=True, + text=True, + check=False, + ) + + +def test_linter_uses_actionlint_schema_and_bounded_shfmt_parser(tmp_path: Path) -> None: + """Large Bash and explicit sh scripts reach shfmt without content loss.""" + + 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", + " cancel-in-progress: false", + "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") + ) + shfmt_records = [ + json.loads(path.read_text(encoding="utf-8")) + for path in sorted(capture_dir.glob("shfmt-*.json")) + ] + assert result.returncode == 0, result.stderr + assert actionlint_args[0] == "-shellcheck=" + assert actionlint_args[-1] == str(workflow) + assert len(shfmt_records) == 2 + assert shfmt_records[0]["args"] == ["-ln", "bash", "-tojson"] + assert shfmt_records[0]["script"].startswith( + 'set -eo pipefail\necho "_________________"\n' + ) + assert len(shfmt_records[0]["script"].encode()) > 65_536 + assert shfmt_records[1]["args"] == ["-ln", "posix", "-tojson"] + assert shfmt_records[1]["script"] == "set -e\necho ok\n" + + +def test_linter_treats_unshelled_container_job_step_as_posix_sh(tmp_path: Path) -> None: + """A container job with no explicit shell defaults to sh, not bash. + + GitHub Actions runs container-job steps under ``sh`` when no ``shell:`` + is configured anywhere in the resolution chain (step, job defaults, + workflow defaults) — unlike non-container Linux/macOS jobs, which + default to ``bash``. A linter that assumes bash here would validate + Bash-only syntax that the runner will actually execute as (potentially + broken) POSIX sh. + """ + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "container-default-shell.yml" + workflow.write_text( + """name: container-default-shell +on: push +jobs: + containerized: + runs-on: ubuntu-24.04 + container: + image: debian:bookworm-slim + steps: + - name: No explicit shell + run: echo ok + bare: + runs-on: ubuntu-24.04 + steps: + - name: No explicit shell, no container + run: echo ok +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + shfmt_records = [ + json.loads(path.read_text(encoding="utf-8")) + for path in sorted(capture_dir.glob("shfmt-*.json")) + ] + assert result.returncode == 0, result.stderr + assert len(shfmt_records) == 2 + # Container job step: no explicit shell anywhere -> sh (posix). + assert shfmt_records[0]["args"] == ["-ln", "posix", "-tojson"] + # Non-container job step: no explicit shell anywhere -> bash, unchanged. + assert shfmt_records[1]["args"] == ["-ln", "bash", "-tojson"] + + +def test_linter_classifies_absolute_path_shell_templates(tmp_path: Path) -> None: + """Custom shell templates naming an absolute Bash/sh path are recognized. + + GitHub Actions accepts any executable, including an absolute path, as a + custom ``shell:`` template (optionally followed by flags and a ``{0}`` + script-path placeholder). A dialect matcher that only recognizes the + bare names "bash"/"sh" silently skips shell-syntax validation for such + steps instead of classifying them by dialect. + """ + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "absolute-path-shell.yml" + workflow.write_text( + """name: absolute-path-shell +on: push +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - name: Absolute bash with flags + shell: '/bin/bash --noprofile --norc -eo pipefail {0}' + run: echo bash-ok + - name: Absolute sh with placeholder + shell: '/usr/bin/sh {0}' + run: echo sh-ok +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + shfmt_records = [ + json.loads(path.read_text(encoding="utf-8")) + for path in sorted(capture_dir.glob("shfmt-*.json")) + ] + assert result.returncode == 0, result.stderr + assert len(shfmt_records) == 2 + assert shfmt_records[0]["args"] == ["-ln", "bash", "-tojson"] + assert shfmt_records[1]["args"] == ["-ln", "posix", "-tojson"] + + +def test_linter_preserves_lines_inside_multiline_expressions(tmp_path: Path) -> None: + """Expression sanitizing keeps shfmt 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 / "shfmt-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 use checksum-pinned tools and the trusted 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 + assert "actionlint_1.7.12_linux_amd64.tar.gz" in workflow + assert ( + "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + in workflow + ) + assert ( + 'tar -xzf "$actionlint_archive" -C "$RUNNER_TEMP" actionlint' + in workflow + ) + assert 'PATH="${RUNNER_TEMP}:${PATH}"' in workflow + + +def test_linter_invokes_fixed_tool_names_without_dynamic_command_selection() -> None: + """Repository input cannot select the executable passed to Open3.""" + + source = LINTER.read_text(encoding="utf-8") + + assert 'ENV.fetch("ACTIONLINT"' not in source + assert 'ENV.fetch("SHFMT"' not in source + assert 'Open3.capture3("actionlint", *arguments)' in source + assert 'Open3.capture3("shfmt", "-ln", "bash", "-tojson"' in source + assert 'Open3.capture3("shfmt", "-ln", "posix", "-tojson"' in source + assert "findings = workflows.sum" not in source + assert source.count( + "# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec" + ) == 1 + + +def test_linter_uses_permissive_pinned_shfmt_instead_of_shellcheck() -> None: + """The write-capable linter must not add a GPL tool dependency.""" + + source = LINTER.read_text(encoding="utf-8") + workflow = AUTOFIX_WORKFLOW.read_text(encoding="utf-8") + + assert 'Open3.capture3("shfmt",' in source + assert 'Open3.capture3("shellcheck",' not in source + assert "shfmt_v3.13.1_linux_amd64" in workflow + assert "fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1" in workflow + + +def test_linter_reports_shfmt_syntax_failures_with_workflow_context(tmp_path: Path) -> None: + """A parser failure identifies the governed workflow job and step.""" + + 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 SYNTAX_ERROR_MARKER +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + assert result.returncode == 2 + assert str(workflow) in result.stderr + assert "job=verify" in result.stderr + assert "step=Unsafe expansion" in result.stderr + assert "standard input:2:7: expected command" 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( + "workflow_source", + ( + """name: cancelled-workflow-queue +on: push +concurrency: + group: exact + queue: max + cancel-in-progress: true +jobs: {} +""", + """name: cancelled-job-queue +on: push +jobs: + verify: + runs-on: ubuntu-24.04 + concurrency: + group: exact + queue: max + cancel-in-progress: true + steps: [] +""", + ), +) +def test_linter_rejects_queue_max_with_static_cancellation( + tmp_path: Path, + workflow_source: str, +) -> None: + """GitHub permits an expanded queue only when cancellation is disabled.""" + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "cancelled-queue.yml" + workflow.write_text(workflow_source, encoding="utf-8") + + result = _run_linter(workflow, environment) + + assert result.returncode == 2 + assert "queue max requires cancel-in-progress to be literal false or absent" 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"), + ({"SHFMT_MALFORMED": "1"}, "invalid shfmt 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 + + +@pytest.mark.parametrize( + "cancel_value", + ( + "${{ github.event_name == 'pull_request' }}", + "${{ false }}", + "yes", + ), +) +def test_linter_rejects_queue_max_without_literal_false( + tmp_path: Path, + cancel_value: str, +) -> None: + """Dynamic or non-boolean cancellation cannot prove a lossless max queue.""" + + environment, capture_dir = _tool_environment(tmp_path) + workflow = tmp_path / "ambiguous-cancellation.yml" + workflow.write_text( + f"""name: ambiguous-cancellation +on: push +concurrency: + group: exact + queue: max + cancel-in-progress: {cancel_value} +jobs: {{}} +""", + encoding="utf-8", + ) + + result = _run_linter(workflow, environment) + + assert result.returncode == 2 + assert "requires cancel-in-progress to be literal false or absent" in result.stderr + assert not (capture_dir / "actionlint.json").exists()