Skip to content
Draft
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
23 changes: 21 additions & 2 deletions .github/workflows/pr-review-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
112 changes: 112 additions & 0 deletions docs/doctoring/actionlint-modern-schema-and-shellcheck.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
189 changes: 189 additions & 0 deletions scripts/ci/lint_github_workflows.rb
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading