diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml index b038c5478e..aeea884e8e 100644 --- a/.github/workflows/exact-artifact-sbom-attestation.yml +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -334,13 +334,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 + 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 \ - | while IFS= read -r evidence_file; do - sha256sum "$evidence_file" - done > SHA256SUMS + | 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 ) chmod 0444 \ offline-attestation-evidence/README.md \ diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 26e8555967..d7f7c18d9f 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -962,9 +962,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() { @@ -1237,6 +1239,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 @@ -1244,6 +1248,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 @@ -1395,6 +1401,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 @@ -1764,6 +1772,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})" corepack 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" && corepack npm run build' bash "$package_dir" fi ;; @@ -1771,6 +1781,8 @@ jobs: if [ -n "$package_name" ] && [ -f pnpm-workspace.yaml ]; then run_and_capture "Tauri frontendDist build (${package_dir})" corepack 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" && corepack pnpm run build' bash "$package_dir" fi ;; @@ -1778,6 +1790,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 ;; @@ -1894,8 +1908,14 @@ 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="" + 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" export WGPU_BACKEND=vulkan @@ -3065,12 +3085,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 } @@ -3350,6 +3375,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 } @@ -3360,12 +3387,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 @@ -4377,6 +4406,8 @@ jobs: "$@" } + # jq expands its own variables inside this literal program. + # shellcheck disable=SC2016 self_check_filter=' def self_check: (.name // "") as $n @@ -5044,6 +5075,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" @@ -5398,6 +5431,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" @@ -6195,6 +6230,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)) @@ -6211,6 +6248,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)) @@ -6275,6 +6314,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" \ @@ -6428,6 +6469,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" \ @@ -6871,6 +6914,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}" \ @@ -6938,6 +6983,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' @@ -6994,10 +7041,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' @@ -7702,6 +7753,7 @@ jobs: SCHEDULER_ACTIONS_TOKEN: ${{ github.token }} SCHEDULER_READ_TOKEN: ${{ (github.event_name == 'pull_request_target' || needs.validate-pr-metadata.outputs.target_repository == github.repository) && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.opencode_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + SCHEDULER_WORKFLOW_TOKEN: ${{ github.token }} GH_REPOSITORY: ${{ needs.validate-pr-metadata.outputs.target_repository }} PR_BASE_REF: ${{ needs.validate-pr-metadata.outputs.base_ref }} PR_NUMBER: ${{ needs.validate-pr-metadata.outputs.pr_number }} 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/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d32918cf45..33bb0c025c 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -496,6 +496,7 @@ jobs: SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + SCHEDULER_WORKFLOW_TOKEN: ${{ github.token }} SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d599a320..108375c8aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1312,6 +1312,18 @@ Semantic Versioning where the repository publishes a release. never saw it and later repositories in the same rotation kept spending the bucket too. It now stops the repository's scan and propagates the error like the pre-loop path already did. +- Separate actionlint schema/expression/Pyflakes validation from its deadlocking + ShellCheck transport, replace the newly added GPL dependency with the + checksum-pinned BSD-3-Clause shfmt 3.13.1 syntax parser, preserve effective + shell and expression semantics for workflow blocks larger than 64 KiB, and + accept GitHub's native `concurrency.queue: max` only when + `cancel-in-progress` is false or absent. +- Bound head-mutation authorization to the actual selected `GH_TOKEN` as well + as its declared source, failing closed when it is missing or resolves to the + workflow `github.token`; case-fold repository host comparisons so casing + drift cannot select the wrong Actions credential or skip same-repository + stale-run cleanup, and render withheld-mutation guidance from the recorded + decision instead of re-reading mutable process credentials. - Web verification now checks services through local readiness addresses only. Start the backend and frontend on this computer and use their local health URLs when running the check. @@ -1329,6 +1341,14 @@ Semantic Versioning where the repository publishes a release. - Publish only the sanitized cumulative Strix report tree, avoiding a later copy of relative scanner output that could reintroduce known internal warning text into uploaded security evidence. +- Install actionlint 1.7.12 and shfmt 3.13.1 from their official release + artifacts with exact SHA-256 verification, then invoke both through fixed + executable names supplied by the trusted step-local `PATH`; this removes the + undocumented runner-image assumption and unused environment-selected command + overrides while preserving fail-closed, no-shell linting. Treat shfmt as the + parser contract it is instead of accumulating an unreachable findings count. + Narrowly suppress Semgrep's remaining false positive on the literal + actionlint call, whose dynamic workflow paths remain separate argv values. - Retry configured Strix fallback models when the primary provider records a rate-limit or infrastructure failure only in its structured report log, and @@ -1357,14 +1377,23 @@ Semantic Versioning where the repository publishes a release. retryable model-protocol evidence, while keeping `Vulnerabilities [1-9]` and other severity signals fail-closed. - Derived `org-queue-sweep`'s rotation index (added in `ContextualWisdomLab/.github#1220` to stop the walk-order starvation from `ContextualWisdomLab/.github#1219`) from a persistent `ORG_SWEEP_ROTATION_COUNTER` repository variable incremented by exactly one at the start of every actual sweep execution, instead of `github.run_number` (which increments on every trigger of this workflow, not only the sweep schedule — Devin review finding on `#1220`) or a wall-clock tick alone (which can repeat an offset when this single-flight, up-to-60-minute job runs behind schedule by an exact multiple of the repository count — CodeRabbit review finding on `#1223`). Falls back to the wall-clock tick only if the persistent counter itself is unavailable, so a fairness mechanism never blocks the sweep's review-dispatch/merge work. -- 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. +- 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, usestrix/strix#1037, usestrix/strix#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 - Actions inventory and read calls, while retaining the established mutation - credential chain. An exhausted organization-wide OpenCode App installation - budget can no longer prevent a central `.github` PR from dispatching its - exact-head review; cross-repository targets still require an explicit - credential. +- Use the repository hosting each workflow run to select scheduler Actions + credentials: central required-workflow inventory and stale-run cancellation + use the receiving repository's job token. Centrally hosted review dispatches + no longer enumerate or cancel non-authoritative target old-head CI before + dispatch; same-repository cleanup and explicit target mutations keep their + established credentials. An exhausted organization-wide OpenCode App + installation budget can no longer stop exact-head review on that cleanup read. + Exact repository-dispatch titles are also matched before GitHub's `name` + field is treated as a workflow alias, preventing duplicate exact-head model + runs when that field contains the configured `run-name`. +- Refused draft pull requests again at both direct-merge and auto-merge mutation + boundaries, even though `inspect_pr` already skips drafts before any mutation. + This defense in depth makes a future caller unable to turn forged check or + review metadata into a draft merge and records Strix run `32573579932` finding + `vuln-0001` without adding a scanner allowlist or weakening required checks. - Kept independently valid root-level Python lock environments separate during trusted base coverage installation. A directory with more than two candidate locks no longer collapses unrelated OpenCode, security, and application 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..63a8c89e0f --- /dev/null +++ b/docs/doctoring/actionlint-modern-schema-and-shellcheck.md @@ -0,0 +1,102 @@ +# 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. + +## 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/doctoring/fork-head-review-dispatch.md b/docs/doctoring/fork-head-review-dispatch.md index fe326459f8..9839001308 100644 --- a/docs/doctoring/fork-head-review-dispatch.md +++ b/docs/doctoring/fork-head-review-dispatch.md @@ -65,6 +65,89 @@ distinguish a same-repository target from a cross-repository target. The full Python suite, 100% statement/branch/docstring gates, and the CI-budget Strix shell gate remain authoritative before publication. +Targeted cross-repository run `32566396712` later exposed the remaining host +boundary: while reviewing `contextual-orchestrator#820`, active OpenCode run +discovery queried the central `.github` Actions inventory with the shared App +token and exhausted that installation's quota before dispatch. Active-run +inventory and stale-run cancellation now select credentials by the repository +hosting the run. Central required-workflow runs use the receiving repository's +job token; target-repo run inventory and mutations retain the explicit +cross-repository credential. The regression exercises discovery and +cancellation on both hosts so a later refactor cannot collapse them back onto +one rate-limit bucket. + +Targeted scheduler run `32569094917` then exposed a second inventory boundary: +GitHub returned the configured `run-name` in the Actions run `name` field for an +already-running exact-head OpenCode dispatch. Filtering that field as a workflow +alias before checking the trusted exact dispatch title missed run `32569021159` +and created duplicate run `32569106868`, which was cancelled before model work. +Central dispatch inventory now validates the exact repository, PR, and head SHA +encoded in the dispatch title before applying the legacy workflow-name filter. +The regression covers both API shapes so only one exact-head model review runs. + +Live retry `32572857921` exposed one remaining pre-dispatch quota consumer. Every +PR inspection unconditionally enumerated queued and running workflows in the +target repository to cancel old-head CI before it examined the centrally hosted +review run. The shared App installation was already rate-limited, so +`contextual-orchestrator#820` stopped on that non-authoritative cleanup read and +never reached exact-head review dispatch. When the required reviewer is hosted +centrally, target-repository old-head jobs do not supply current-head approval or +merge evidence and the central dispatch functions already deduplicate and cancel +their own stale review runs. Centralized inspections therefore skip only that +target old-head inventory/cancellation step. Same-repository schedulers retain it, +and all current-head checks, review identity, target reads needed for live PR +validation, and explicit target mutations remain fail-closed. This removes two +target Actions-list requests per inspected PR without widening any authority. + +Exact-head Strix [run 32579981586](https://github.com/ContextualWisdomLab/.github/actions/runs/32579981586) +then reported a HIGH mismatch between the +declared mutation-credential source and the token actually inherited by `gh`. +Its illustrative fallback helper was not present in the scheduler, and the +workflow expressions select `GH_TOKEN` and `SCHEDULER_MUTATION_TOKEN_SOURCE` +from the same precedence chain. The executable boundary nevertheless relied on +that expression-level coupling: a missing token or inconsistent GitHub App +`available` output could select the runner `github.token` while the Python +guard still trusted the stronger source label. + +Every scheduler mutation entrypoint now receives the runner token separately +as `SCHEDULER_WORKFLOW_TOKEN`. A head update is authorized only when the source +is allowlisted, the selected `GH_TOKEN` and comparison token are both present, +and the two actual token values differ. Neither value is logged. Tests cover an +empty selected token and a source-label/runner-token mismatch, while the offline +self-test uses distinct synthetic values. Repository-host identity comparisons +also use case-folded canonical names, so a case-only spelling difference cannot +move central Actions inventory onto a shared App credential or skip +same-repository stale-run cleanup. This is a zero-trust verification at the +mutation boundary rather than trust in an upstream environment label (Rose et +al., 2020). + +The scheduler also records the credential refusal in each immutable decision +reason and renders later JSON and Actions guidance from that captured evidence. +It does not re-read mutable process credentials while serializing a decision, +so a surrounding test or caller cannot turn a valid wait into a summary-time +exception by changing the environment after inspection. + +## Draft merge defense in depth + +Exact-head Strix run `32573579932` reported `vuln-0001`, alleging that a draft +pull request could forge successful checks and reach merge without OpenCode +approval. The proposed proof of concept does not traverse the executable +control flow: `inspect_pr` returns `skip: draft PR` before stale-run cleanup, +review interpretation, auto-merge, or direct merge, and an arbitrary author's +review is not an exact-head OpenCode approval. The report also assumed a fork +pull-request token could create base-repository check runs and approvals, +contrary to the least-privilege fork boundary documented by GitHub (GitHub, +Inc., n.d.-b). + +The finding is retained as security evidence rather than broadly suppressed. +As defense in depth against a future caller bypassing `inspect_pr`, both guarded +merge mutation functions now reject `isDraft` before actor validation or any +GitHub call. The regression invokes both mutation boundaries with a valid head +SHA and asserts an exception plus zero outbound commands. The existing +top-level draft regression remains, and a new exact-head Strix run must clear +the changed code; no scanner severity, check requirement, workflow identity, or +finding allowlist changed. + ## APA 7th references GitHub, Inc. (n.d.-a). *REST API endpoints for pull requests*. GitHub Docs. @@ -82,6 +165,10 @@ https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api GitHub, Inc. (n.d.-d). *GITHUB_TOKEN*. GitHub Docs. Retrieved August 22, 2026, from https://docs.github.com/en/actions/concepts/security/github_token +Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust +architecture* (NIST Special Publication 800-207). National Institute of +Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207 + Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National diff --git a/scripts/ci/lint_github_workflows.rb b/scripts/ci/lint_github_workflows.rb new file mode 100644 index 0000000000..c90ad536a1 --- /dev/null +++ b/scripts/ci/lint_github_workflows.rb @@ -0,0 +1,188 @@ +#!/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 + return unless concurrency["cancel-in-progress"] == true + + raise WorkflowLintError, + "#{path}: #{label} concurrency queue max requires cancel-in-progress to be 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/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index c4e9d28ebd..3248fa48e5 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -455,8 +455,8 @@ def mutation_token_label() -> str: return labels.get(source, "workflow GH_TOKEN") -def head_mutation_credential_starts_workflows() -> bool: - """Return whether scheduler head mutations can start required workflow runs. +def head_mutation_credential_problem() -> str | None: + """Explain why the selected mutation credential cannot start workflow runs. GitHub never creates a new workflow run for an event produced with the workflow ``GITHUB_TOKEN``, so a PR head moved with that credential can never @@ -467,22 +467,41 @@ def head_mutation_credential_starts_workflows() -> bool: GitHub. (2025). *Automatic token authentication*. https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication """ - return mutation_token_source() in WORKFLOW_STARTING_MUTATION_SOURCES - - -def non_triggering_head_mutation_reason(action: str) -> str: - """Explain why a head mutation is withheld for a non-triggering credential.""" source = mutation_token_source() if source == "github-token": - credential_reason = ( - "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + return "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + if source not in WORKFLOW_STARTING_MUTATION_SOURCES: + return f"{mutation_token_label()} is not allowlisted as workflow-starting" + + selected_token = (os.environ.get("GH_TOKEN") or "").strip() + workflow_token = (os.environ.get("SCHEDULER_WORKFLOW_TOKEN") or "").strip() + if not selected_token: + return f"{mutation_token_label()} is missing and therefore not proven workflow-starting" + if not workflow_token: + return ( + "workflow GITHUB_TOKEN comparison evidence is missing, so the selected mutation " + "credential is not proven workflow-starting" ) - else: - credential_reason = ( - f"the {mutation_token_label()}, which is not allowlisted as workflow-starting" + if selected_token == workflow_token: + return ( + f"{mutation_token_label()} resolved to the workflow GITHUB_TOKEN, whose head " + "mutations never start new workflow runs" ) + return None + + +def head_mutation_credential_starts_workflows() -> bool: + """Return whether the actual scheduler mutation token can start workflow runs.""" + return head_mutation_credential_problem() is None + + +def non_triggering_head_mutation_reason(action: str) -> str: + """Explain why a head mutation is withheld for a non-triggering credential.""" + credential_reason = head_mutation_credential_problem() + if credential_reason is None: + raise RuntimeError("withheld-mutation messaging requires a non-triggering mutation credential") return ( - f"{action} withheld because the scheduler mutation credential is {credential_reason}, " + f"{action} withheld because {credential_reason}, " "so the moved head would stay permanently " "BLOCKED without current-head required checks; configure PR_REVIEW_MERGE_TOKEN, " "OPENCODE_APPROVE_TOKEN, or the OpenCode app token for the scheduler job" @@ -495,15 +514,10 @@ def require_workflow_starting_mutation_credential(action: str) -> None: raise RuntimeError(non_triggering_head_mutation_reason(action)) -def head_mutation_credential_guidance_text() -> tuple[str, str]: - """Return operator-facing summary and limit text for a withheld head mutation.""" - if mutation_token_source() == "github-token": - return ( - "The scheduler withheld a head mutation because the workflow GITHUB_TOKEN cannot start the required current-head workflow runs.", - "Moving the head with the workflow GITHUB_TOKEN would leave the PR permanently BLOCKED, so the scheduler waits instead.", - ) +def head_mutation_credential_guidance_text(withheld_reason: str) -> tuple[str, str]: + """Render operator guidance from the credential decision already recorded.""" return ( - f"The scheduler withheld a head mutation because {mutation_token_label()} is not allowlisted as workflow-starting.", + f"The scheduler withheld a head mutation. Recorded decision: {withheld_reason}", "Moving the head is unsafe until the scheduler can prove that the selected credential starts the required current-head workflow runs.", ) @@ -654,7 +668,7 @@ def decision_guidance(decision: Decision) -> dict[str, Any] | None: ], } if parse_non_triggering_head_mutation_reason(decision.reason): - summary, automation_limit = head_mutation_credential_guidance_text() + summary, automation_limit = head_mutation_credential_guidance_text(decision.reason) return { "type": "head_mutation_credential_upgrade", "token": mutation_token_label(), @@ -826,6 +840,19 @@ def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str return run_with_env(args, stdin=stdin, env=env) +def run_github_actions_for_repository( + repo: str, + args: Sequence[str], +) -> str: + """Run an Actions command with the credential scoped to its host repository.""" + central_repo = ( + os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "" + ).strip() + if central_repo and repo.casefold() == central_repo.casefold(): + return run_github_dispatch(args) + return run_github_actions(args) + + def split_repo(repo: str) -> tuple[str, str]: """Split an owner/name repository string into owner and repository name.""" try: @@ -1528,7 +1555,11 @@ def compare_ref_for_pr_head(repo: str, pr: dict[str, Any]) -> str: """Return the compare-API head ref for a PR branch.""" head_ref = pr.get("headRefName") or "HEAD" head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") - if not head_repo or head_repo == repo: + # GitHub repository identity is case-insensitive: casefold both sides so + # a same-repository head whose GitHub-reported canonical name differs + # only in case from the configured target is still treated as + # same-repository, matching same_repository_head below. + if not head_repo or head_repo.casefold() == repo.casefold(): return head_ref head_owner, _ = split_repo(head_repo) return f"{head_owner}:{head_ref}" @@ -2616,6 +2647,8 @@ def run_head_guarded_merge( def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: """Enable auto-merge for a PR at its current head using an allowed method.""" + if pr.get("isDraft"): + raise RuntimeError("enable-auto-merge refused for draft PR") number = str(pr["number"]) if dry_run: return @@ -2626,6 +2659,8 @@ def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: def merge_pr(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: """Merge a current-head-approved PR immediately with a head guard.""" + if pr.get("isDraft"): + raise RuntimeError("direct-merge refused for draft PR") number = str(pr["number"]) if dry_run: return @@ -2937,7 +2972,13 @@ def post_update_branch_followup( def same_repository_head(repo: str, pr: dict[str, Any]) -> bool: """Return whether the PR head branch belongs to the repository being scanned.""" head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") - return head_repo == repo + # GitHub repository identity is case-insensitive; casefold both sides so + # a same-repository PR whose GitHub-reported canonical name differs only + # in case from the configured target repo is not misclassified as + # cross-repository, matching the existing case-insensitive comparisons + # already used elsewhere in this file (e.g. the stale-run-cancellation + # gate and the central-repository dispatch-target check). + return bool(head_repo) and head_repo.casefold() == repo.casefold() def can_update_pr_head(repo: str, pr: dict[str, Any]) -> bool: @@ -3162,7 +3203,7 @@ def active_workflow_runs( args += ["-f", f"created={created}"] if head_sha: args += ["-f", f"head_sha={head_sha}"] - payload = json.loads(run_github_actions(args)) + payload = json.loads(run_github_actions_for_repository(repo, args)) pages = payload if isinstance(payload, list) else [payload] for page in pages: runs.extend(page.get("workflow_runs") or []) @@ -3251,9 +3292,6 @@ def active_review_run_refs( # must not suppress the central authenticated reviewer. for run_repo in (dispatch_repo,): for run_data in active_workflow_runs(run_repo, statuses): - run_name = str(run_data.get("name") or "") - if run_name != workflow and run_name not in workflow_aliases: - continue run_id = run_data.get("id") if not run_id: continue @@ -3273,6 +3311,9 @@ def active_review_run_refs( continue (current if dispatched_head == head else stale).append(run_ref) continue + run_name = str(run_data.get("name") or "") + if run_name != workflow and run_name not in workflow_aliases: + continue if centralized_dispatch: continue run_head = str(run_data.get("head_sha") or "").lower() @@ -3394,7 +3435,8 @@ def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> dict[str, s def cancel_one(run_id: str) -> tuple[str, str | None]: """Return one run id and its bounded GitHub cancellation error, if any.""" try: - run_github_actions( + run_github_actions_for_repository( + repo, [ "gh", "api", @@ -4228,7 +4270,11 @@ def inspect_pr( pass run(["gh", "pr", "close", str(number), "--repo", repo]) return Decision(number, "close_empty", "base 대비 실제 변경 0건") - cancel_stale_pr_runs(repo, pr, dry_run=dry_run) + # Central reviewers own their run lifecycle in the dispatch repository. + # Target old-head CI is non-authoritative, and enumerating it can exhaust + # the installation quota before the current-head review is dispatched. + if repository_dispatch_target(repo).casefold() == repo.casefold(): + cancel_stale_pr_runs(repo, pr, dry_run=dry_run) if base_ref != base_branch: # Stacked/cascade PR (base is another feature branch). Org required # workflows are only injected for default-branch-target PRs, so these @@ -5180,7 +5226,7 @@ def head_mutation_credential_upgrade_summary(decisions: list[Decision]) -> list[ waits = [decision for decision in decisions if parse_non_triggering_head_mutation_reason(decision.reason)] if not waits: return [] - summary, automation_limit = head_mutation_credential_guidance_text() + summary, automation_limit = head_mutation_credential_guidance_text(waits[0].reason) lines = ["", "### Head mutation withheld", "", summary, automation_limit] lines.extend( [ @@ -5199,6 +5245,8 @@ def parse_non_triggering_head_mutation_reason(reason: str) -> bool: return ( "whose head mutations never start new workflow runs" in reason or "which is not allowlisted as workflow-starting" in reason + or "is not allowlisted as workflow-starting" in reason + or "not proven workflow-starting" in reason ) @@ -5396,16 +5444,28 @@ def summarize_action_error(exc: RuntimeError) -> str: @contextlib.contextmanager def declared_mutation_token_source(source: str) -> Iterator[None]: - """Declare a scheduler mutation credential source for the enclosed block.""" - previous = os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") + """Declare a coherent synthetic mutation credential for offline self-tests.""" + keys = ( + "SCHEDULER_MUTATION_TOKEN_SOURCE", + "GH_TOKEN", + "SCHEDULER_WORKFLOW_TOKEN", + ) + previous = {key: os.environ.get(key) for key in keys} os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = source + os.environ["SCHEDULER_WORKFLOW_TOKEN"] = "self-test-workflow-token" + os.environ["GH_TOKEN"] = ( + "self-test-workflow-token" + if source == "github-token" + else "self-test-selected-mutation-token" + ) try: yield finally: - if previous is None: - os.environ.pop("SCHEDULER_MUTATION_TOKEN_SOURCE", None) - else: - os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = previous + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value def self_test() -> None: diff --git a/tests/test_agent_mention_sweep_regressions.py b/tests/test_agent_mention_sweep_regressions.py index 643f562cc0..4f5c171896 100644 --- a/tests/test_agent_mention_sweep_regressions.py +++ b/tests/test_agent_mention_sweep_regressions.py @@ -4,6 +4,7 @@ import importlib import sys +import threading from datetime import datetime, timezone from pathlib import Path @@ -94,11 +95,26 @@ def test_pull_pagination_stops_at_cutoff_without_loading_later_pages() -> None: assert sweep.flatten_pages([{"number": 1}]) == [{"number": 1}] -def test_recent_pull_requests_use_bounded_parallel_repository_fetches(monkeypatch) -> None: - """Repository fetches are parallel but results remain repository ordered.""" +def test_recent_pull_requests_emit_bounded_parallel_fetches_as_they_finish( + monkeypatch, +) -> None: + """A slow repository cannot hide a completed sibling repository result.""" sweep = module() - client = PagingClient( + second_observed = threading.Event() + + class CompletionOrderClient(PagingClient): + """Hold the first repository until the second one has completed.""" + + def request(self, args, *, input_payload=None): + """Make repository completion order deterministic for the assertion.""" + + endpoint = args[0] + if endpoint == "repos/ContextualWisdomLab/first/pulls": + assert second_observed.wait(timeout=30) + return super().request(args, input_payload=input_payload) + + client = CompletionOrderClient( { ("orgs/ContextualWisdomLab/repos", 1): [[ repository("first"), @@ -120,17 +136,18 @@ def recording_executor(*, max_workers): "ThreadPoolExecutor", recording_executor, ) - results = list( - sweep.list_recent_pull_requests( - client, - organization="ContextualWisdomLab", - repository_source="organization", - since="2026-08-05T00:00:00Z", - ) + issues = sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-05T00:00:00Z", ) + first_result = next(issues) + second_observed.set() + results = [first_result, *issues] assert [result["repository"] for result in results] == [ - "ContextualWisdomLab/first", "ContextualWisdomLab/second", + "ContextualWisdomLab/first", ] assert worker_limits == [2] diff --git a/tests/test_exact_artifact_sbom_attestation_contract.py b/tests/test_exact_artifact_sbom_attestation_contract.py index 221f93244a..147fc59005 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_lint_github_workflows.py b/tests/test_lint_github_workflows.py new file mode 100644 index 0000000000..ea680f57e2 --- /dev/null +++ b/tests/test_lint_github_workflows.py @@ -0,0 +1,457 @@ +"""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 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 diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 37ec068db9..7d6812e3bd 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2383,6 +2383,9 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): in workflow ) assert "SCHEDULER_MUTATION_TOKEN_SOURCE" in workflow + # One scheduler job carries the workflow-token evidence. The branch pinned + # two because it also set it on org-queue-sweep, a job main has since removed. + assert workflow.count("SCHEDULER_WORKFLOW_TOKEN: ${{ github.token }}") == 1 assert 'default: "1"' in workflow assert 'review_dispatch_limit="-1"' in workflow assert "branch_update_limit:" in workflow @@ -2482,6 +2485,7 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( "'OPENCODE_APPROVE_TOKEN' || steps.opencode_app_token.outputs.available == 'true' && " "'opencode-app' || 'github-token' }}" ) in workflow + assert "SCHEDULER_WORKFLOW_TOKEN: ${{ github.token }}" in workflow assert "--no-trigger-reviews" in workflow assert "--enable-auto-merge" in workflow assert "--no-update-branches" in workflow diff --git a/tests/test_opencode_rust_coverage_toolchain_contract.py b/tests/test_opencode_rust_coverage_toolchain_contract.py index cc0c49af6f..d0fc4e43fd 100644 --- a/tests/test_opencode_rust_coverage_toolchain_contract.py +++ b/tests/test_opencode_rust_coverage_toolchain_contract.py @@ -70,6 +70,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 2d2304aaf1..b7dae48f98 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -17,7 +17,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 = "26e8555967171a5f3974602ac05700c27bddebf1" +REVIEW_DISPATCH_BLOB_SHA = "d7f7c18d9fc520e3ae91d58d808ccca9d8e57771" def _workflow_text(path: Path) -> str: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 2cbda7f85b..e7a568ccd2 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -34,6 +34,8 @@ def workflow_starting_mutation_credential(monkeypatch): workflow-starting credential exactly like the scheduler workflow does. """ monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") @pytest.fixture(autouse=True) @@ -4628,6 +4630,21 @@ def fake_run(args, stdin=None): ] +def test_draft_pr_cannot_reach_merge_mutations(monkeypatch): + """Defense in depth rejects drafts at both guarded merge boundaries.""" + calls = [] + monkeypatch.setattr(sched, "run", lambda args: calls.append(args) or "") + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GH_TOKEN", "workflow-token") + draft_pr = make_pr(isDraft=True, headRefOid="a" * 40) + + for mutation in (sched.enable_auto_merge, sched.merge_pr): + with pytest.raises(RuntimeError, match="draft PR"): + mutation("owner/repo", draft_pr, dry_run=False) + + assert calls == [] + + def test_last_push_approval_restamp_creates_same_tree_child(monkeypatch): calls = [] head_sha = "a" * 40 @@ -4713,16 +4730,26 @@ def test_head_mutations_refuse_the_workflow_github_token(monkeypatch): def test_declared_mutation_token_source_restores_the_previous_environment(monkeypatch): - """The declaration helper restores both a set and an unset prior value.""" + """The declaration helper restores source and token evidence after self-tests.""" monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "opencode-app") + monkeypatch.setenv("GH_TOKEN", "prior-selected-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "prior-workflow-token") with sched.declared_mutation_token_source("github-token"): assert sched.mutation_token_source() == "github-token" + assert not sched.head_mutation_credential_starts_workflows() assert sched.mutation_token_source() == "opencode-app" + assert os.environ["GH_TOKEN"] == "prior-selected-token" + assert os.environ["SCHEDULER_WORKFLOW_TOKEN"] == "prior-workflow-token" monkeypatch.delenv("SCHEDULER_MUTATION_TOKEN_SOURCE", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("SCHEDULER_WORKFLOW_TOKEN", raising=False) with sched.declared_mutation_token_source("PR_REVIEW_MERGE_TOKEN"): assert sched.mutation_token_source() == "PR_REVIEW_MERGE_TOKEN" + assert sched.head_mutation_credential_starts_workflows() assert "SCHEDULER_MUTATION_TOKEN_SOURCE" not in os.environ + assert "GH_TOKEN" not in os.environ + assert "SCHEDULER_WORKFLOW_TOKEN" not in os.environ def test_workflow_starting_credentials_allow_head_mutations(monkeypatch): @@ -4733,6 +4760,59 @@ def test_workflow_starting_credentials_allow_head_mutations(monkeypatch): sched.require_workflow_starting_mutation_credential("update-branch") +def test_withheld_mutation_reason_rejects_a_workflow_starting_credential(monkeypatch): + """A safe credential cannot create a contradictory withheld-mutation reason.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + + with pytest.raises(RuntimeError, match="requires a non-triggering mutation credential"): + sched.non_triggering_head_mutation_reason("update-branch") + + +def test_withheld_mutation_guidance_uses_recorded_reason_after_environment_changes( + monkeypatch, +): + """Render a captured wait decision without re-reading mutable token state.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "github-token") + reason = sched.non_triggering_head_mutation_reason("branch update") + + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") + assert sched.head_mutation_credential_starts_workflows() + + decision = sched.Decision(7, "wait", reason) + guidance = sched.decision_guidance(decision) + assert guidance is not None + assert "workflow GITHUB_TOKEN" in guidance["summary"] + assert "workflow GITHUB_TOKEN" in "\n".join( + sched.head_mutation_credential_upgrade_summary([decision]) + ) + + +@pytest.mark.parametrize( + ("selected_token", "workflow_token", "message"), + ( + ("", "workflow-runner-token", "is missing"), + ("selected-mutation-token", "", "comparison evidence is missing"), + ("workflow-runner-token", "workflow-runner-token", "resolved to"), + ), +) +def test_declared_workflow_starting_source_cannot_mask_runner_token_fallback( + monkeypatch, + selected_token, + workflow_token, + message, +): + """A missing credential that resolves to github.token cannot move a PR head.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", selected_token) + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", workflow_token) + + assert not sched.head_mutation_credential_starts_workflows() + with pytest.raises(RuntimeError, match=message): + sched.require_workflow_starting_mutation_credential("update-branch") + + def test_unknown_mutation_credential_source_is_fail_closed(monkeypatch): """An unrecognized credential source cannot authorize a head mutation.""" monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "unrecognized-token") @@ -5235,6 +5315,38 @@ def fake_run_with_env(args, *, stdin=None, env=None): ) == "stale_head" +def test_central_workflow_runs_use_central_runner_token_for_central_dispatch( + monkeypatch, +): + """Central run discovery and cancellation must not spend the App quota.""" + calls = [] + + def fake_run_with_env(args, *, stdin=None, env=None): + calls.append((tuple(args), None if env is None else env.get("GH_TOKEN"))) + return '{"workflow_runs": []}' + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GH_TOKEN", "opencode-app-token") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "cross-repository-actions-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "central-runner-token") + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "contextualwisdomlab/.GITHUB", + ) + + sched.active_workflow_runs("ContextualWisdomLab/.github", statuses=("queued",)) + sched.force_cancel_workflow_runs("ContextualWisdomLab/.github", ["101"]) + sched.active_workflow_runs("owner/repo", statuses=("queued",)) + sched.force_cancel_workflow_runs("owner/repo", ["202"]) + + assert [call[1] for call in calls] == [ + "central-runner-token", + "central-runner-token", + "cross-repository-actions-token", + "cross-repository-actions-token", + ] + + def test_missing_evidence_dispatch_uses_central_required_workflow_repository(monkeypatch): calls = [] head_sha = "a" * 40 @@ -5659,10 +5771,11 @@ def repeated_page(*args, **kwargs): @pytest.mark.parametrize( - ("workflow_name", "run_title"), + ("workflow_name", "run_title", "configured_run_name"), [ - ("OpenCode Review Dispatch", "OpenCode Review Dispatch"), - ("Required OpenCode Review", "Required OpenCode Review"), + ("OpenCode Review Dispatch", "OpenCode Review Dispatch", False), + ("Required OpenCode Review", "Required OpenCode Review", False), + ("OpenCode Review Dispatch", "OpenCode Review Dispatch", True), ], ) def test_dispatch_opencode_review_deduplicates_current_head_repository_dispatch( @@ -5670,15 +5783,17 @@ def test_dispatch_opencode_review_deduplicates_current_head_repository_dispatch( capsys, workflow_name, run_title, + configured_run_name, ): calls = [] head_sha = "a" * 40 + display_title = f"{run_title} owner/repo#1@{head_sha}" current_dispatch = { "id": 9100, - "name": workflow_name, + "name": display_title if configured_run_name else workflow_name, "event": "repository_dispatch", "head_sha": "default-branch-sha", - "display_title": f"{run_title} owner/repo#1@{head_sha}", + "display_title": display_title, "pull_requests": [], } @@ -7207,6 +7322,23 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): assert not sched.can_update_pr_head("owner/repo", external_behind) assert sched.can_update_pr_head("owner/repo", external_mutable) assert "same-repository head update permission" in sched.non_mutable_head_reason("owner/repo", behind) + # Regression: GitHub repository identity is case-insensitive. A PR whose + # GitHub-reported canonical headRepository differs from the configured + # target repo only by case must still be classified same-repository, so + # it keeps branch-update and merge eligibility instead of being + # misrouted onto the external/fork head path. + same_repo_different_case = make_pr( + mergeStateStatus="BEHIND", + headRepository={"nameWithOwner": "Owner/Repo"}, + reviews={"nodes": [opencode_review("APPROVED", "head")]}, + ) + assert sched.same_repository_head("owner/repo", same_repo_different_case) + assert sched.can_update_pr_head("owner/repo", same_repo_different_case) + assert sched.compare_ref_for_pr_head("owner/repo", same_repo_different_case) == "feature" + same_case_decision = inspect(same_repo_different_case) + assert same_case_decision.action == "update_branch" + assert called == [("owner/repo", 1, True)] + called.clear() behind_failed = make_pr( mergeStateStatus="BEHIND", reviews={"nodes": [opencode_review("APPROVED", "head")]}, @@ -7896,6 +8028,7 @@ def test_workflow_run_filters_skip_mismatched_workflow_and_current_head_other_pr def test_inspect_pr_cancels_stale_queued_runs_before_decision(monkeypatch): + monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "OWNER/REPO") cancelled = [] monkeypatch.setattr( sched, @@ -7909,6 +8042,27 @@ def test_inspect_pr_cancels_stale_queued_runs_before_decision(monkeypatch): assert cancelled == [("owner/repo", 1, True)] +def test_central_dispatch_skips_non_authoritative_target_actions_inventory( + monkeypatch, +): + """Central review dispatch must not spend App quota on target old-head runs.""" + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr( + sched, + "cancel_stale_pr_runs", + lambda *args, **kwargs: pytest.fail( + "central dispatch must not enumerate target Actions runs" + ), + ) + + decision = inspect(make_pr(baseRefName="feature-base"), trigger_reviews=False) + + assert decision.action == "skip" + + def test_inspect_pr_blocks_auto_merge_for_approved_conflicts(monkeypatch): auto_merges = [] disables = [] diff --git a/tests/test_strix_nvidia_nim_not_found_fallback.py b/tests/test_strix_nvidia_nim_not_found_fallback.py index ba8344455b..7f16814c85 100644 --- a/tests/test_strix_nvidia_nim_not_found_fallback.py +++ b/tests/test_strix_nvidia_nim_not_found_fallback.py @@ -2,8 +2,8 @@ The central Strix workflow must not turn a provider-side model-catalog 404 into a security finding or retry the same unavailable model. It must move to another -approved free NVIDIA NIM candidate before using the existing GitHub Models -fallbacks, while ordinary application 404 output remains non-retryable. +approved free NVIDIA NIM candidate before using the reviewed direct OpenAI +fallback, while ordinary application 404 output remains non-retryable. """ from __future__ import annotations