diff --git a/.github/workflows/update-migration.yml b/.github/workflows/update-migration.yml index cf671c49e2a7..e5bf4fbec5b4 100644 --- a/.github/workflows/update-migration.yml +++ b/.github/workflows/update-migration.yml @@ -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. diff --git a/.github/workflows/windows-testbox-probe.yml b/.github/workflows/windows-testbox-probe.yml index 2573a5ebda54..e65b8275985c 100644 --- a/.github/workflows/windows-testbox-probe.yml +++ b/.github/workflows/windows-testbox-probe.yml @@ -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()) diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index f4a75b4ec368..da6fcdb73cd0 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -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 diff --git a/docs/ci.md b/docs/ci.md index a75c91ac4968..b455072808ab 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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. diff --git a/scripts/check-workflows.mjs b/scripts/check-workflows.mjs index ff9b09e4eaaa..2658ac265360 100644 --- a/scripts/check-workflows.mjs +++ b/scripts/check-workflows.mjs @@ -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"]); diff --git a/test/scripts/check-workflows.test.ts b/test/scripts/check-workflows.test.ts index 5e7e18096a96..c03b11d8053a 100644 --- a/test/scripts/check-workflows.test.ts +++ b/test/scripts/check-workflows.test.ts @@ -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 }); }