fix(ci): guard workflow template injection

Guard the remaining Windows Testbox workflow ref logging against GitHub Actions template injection by moving `target_ref` through step env before PowerShell reads it.

Extend the local workflow check wrapper to run pinned `zizmor` across every workflow file, and keep Workflow Sanity's CI audit explicit with trusted-base pre-commit and zizmor configs for pull-request runs.

Thanks @WT-WSL for the original report and patch.

Co-authored-by: dev111-actor <captaintobb@outlook.com>
This commit is contained in:
WT-WSL
2026-05-31 09:28:40 -10:00
committed by GitHub
parent 118b9cacf6
commit 462b52f62c
6 changed files with 137 additions and 13 deletions

View File

@@ -43,4 +43,4 @@ jobs:
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
telegram_mode: none
secrets: inherit
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.

View File

@@ -61,12 +61,14 @@ jobs:
submodules: false
- name: Probe native Windows
env:
TARGET_REF: ${{ inputs.target_ref || github.ref }}
run: |
$ErrorActionPreference = "Stop"
Write-Host "runner=$env:RUNNER_NAME"
Write-Host "machine=$env:COMPUTERNAME"
Write-Host "workspace=$env:GITHUB_WORKSPACE"
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
Write-Host "target_ref=$env:TARGET_REF"
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())

View File

@@ -84,6 +84,65 @@ jobs:
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Prepare trusted workflow audit configs
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
> "$trusted_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
> "$trusted_zizmor_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
from pathlib import Path
import sys
config_path = Path(sys.argv[1])
zizmor_config_path = sys.argv[2]
text = config_path.read_text()
if ".github/zizmor.yml" not in text:
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
PY
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
- name: Install pre-commit
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
- name: Install actionlint
shell: bash
run: |
@@ -103,6 +162,15 @@ jobs:
- name: Lint workflows
run: actionlint
- name: Audit all workflows with zizmor
shell: bash
run: |
set -euo pipefail
mapfile -t workflow_files < <(
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
)
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

View File

@@ -15,7 +15,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
@@ -80,6 +80,7 @@ apply to that PR.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.

View File

@@ -1,10 +1,13 @@
#!/usr/bin/env node
// Runs local workflow sanity checks.
// Uses an installed actionlint when present, otherwise falls back to `go run`
// for the pinned version used by CI, then runs repo-specific composite guards.
// Uses installed tools when present, otherwise falls back to pinned hooks where
// possible, then runs repo-specific workflow guards.
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { join } from "node:path";
const ACTIONLINT_VERSION = "1.7.11";
const WORKFLOW_DIR = ".github/workflows";
function commandExists(command, args = ["--version"]) {
const result = spawnSync(command, args, { stdio: "ignore" });
@@ -22,16 +25,49 @@ function run(command, args) {
}
}
if (commandExists("actionlint")) {
run("actionlint", []);
} else if (commandExists("go", ["version"])) {
run("go", ["run", `github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}`]);
} else {
function workflowFiles() {
return readdirSync(WORKFLOW_DIR)
.filter((file) => file.endsWith(".yml") || file.endsWith(".yaml"))
.toSorted()
.map((file) => join(WORKFLOW_DIR, file));
}
function runPreCommitHook(hook, files) {
const hookArgs = ["run", "--config", ".pre-commit-config.yaml", hook, "--files", ...files];
if (commandExists("pre-commit")) {
run("pre-commit", hookArgs);
return;
}
if (commandExists("python3", ["-m", "pre_commit", "--version"])) {
run("python3", ["-m", "pre_commit", ...hookArgs]);
return;
}
console.error(
`[check-workflows] missing workflow linter: install actionlint or Go ${ACTIONLINT_VERSION} fallback support.`,
`[check-workflows] missing pre-commit runtime for ${hook}: install pre-commit or python3 pre_commit.`,
);
process.exit(1);
}
const workflows = workflowFiles();
if (commandExists("actionlint")) {
run("actionlint", workflows);
} else if (commandExists("go", ["version"])) {
run("go", ["run", `github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}`]);
} else if (
commandExists("pre-commit") ||
commandExists("python3", ["-m", "pre_commit", "--version"])
) {
runPreCommitHook("actionlint", workflows);
} else {
console.error(
`[check-workflows] missing workflow linter: install actionlint, Go ${ACTIONLINT_VERSION} fallback support, or pre-commit.`,
);
process.exit(1);
}
runPreCommitHook("zizmor", workflows);
run("python3", ["scripts/check-composite-action-input-interpolation.py"]);
run("node", ["scripts/check-no-conflict-markers.mjs"]);

View File

@@ -18,14 +18,15 @@ describe("check-workflows", () => {
expect(result.status).toBe(1);
expect(result.stderr).toContain("missing workflow linter");
expect(result.stderr).toContain("install actionlint or Go");
expect(result.stderr).toContain("install actionlint, Go");
});
it("uses the pinned go fallback when actionlint is unavailable", () => {
it("uses the pinned go fallback and audits all workflows with zizmor", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "check-workflows-"));
try {
const binDir = path.join(tempDir, "bin");
const markerPath = path.join(tempDir, "go-run.txt");
const preCommitMarkerPath = path.join(tempDir, "pre-commit.txt");
mkdirSync(binDir);
writeFileSync(
path.join(binDir, "go"),
@@ -38,6 +39,17 @@ describe("check-workflows", () => {
].join("\n"),
{ mode: 0o755 },
);
writeFileSync(
path.join(binDir, "pre-commit"),
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then exit 0; fi',
'printf "%s\\n" "$*" >> "$PRE_COMMIT_MARKER"',
"exit 0",
"",
].join("\n"),
{ mode: 0o755 },
);
for (const command of ["python3", "node"]) {
writeFileSync(path.join(binDir, command), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
}
@@ -47,6 +59,7 @@ describe("check-workflows", () => {
env: {
...process.env,
GO_FALLBACK_MARKER: markerPath,
PRE_COMMIT_MARKER: preCommitMarkerPath,
PATH: binDir,
},
});
@@ -55,6 +68,10 @@ describe("check-workflows", () => {
expect(readFileSync(markerPath, "utf8")).toContain(
"github.com/rhysd/actionlint/cmd/actionlint@v1.7.11",
);
const preCommitArgs = readFileSync(preCommitMarkerPath, "utf8");
expect(preCommitArgs).toContain("run --config .pre-commit-config.yaml zizmor --files");
expect(preCommitArgs).toContain(".github/workflows/ci.yml");
expect(preCommitArgs).toContain(".github/workflows/windows-testbox-probe.yml");
} finally {
rmSync(tempDir, { force: true, recursive: true });
}