From 5a723728feccfc5521dc4e5cb06237892d393553 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 00:44:48 -0400 Subject: [PATCH 1/9] feat(workflows): add reusable language CI + docker-publish + required aggregator Introduce org-wide reusable workflows that consumer repos can call via `uses: resq-software/.github/.github/workflows/@`: - rust-ci.yml: fmt, clippy, test (optional coverage), cargo-deny. - python-ci.yml: ruff, mypy, pytest matrix across 3.11/3.12/3.13, wheel build. - node-ci.yml: bun (default) / npm / pnpm / yarn with configurable lint, typecheck, test, build commands. - docker-publish.yml: buildx push to ghcr.io with SLSA build-provenance attestation (OIDC keyless, sigstore-backed). Emits image digest output. - required.yml: aggregator that dispatches to language CI by `lang` input, always runs the existing security-scan.yml, and emits a single `required` status-check context for Ruleset A. All third-party actions SHA-pinned with trailing `# ` comment so Dependabot can still propose updates. Every job starts with step-security/harden-runner@f808768d (egress-policy: audit) matching the pattern already established in security-scan.yml. ops/governance-payloads.md documents the org-level PATCH/POST payloads for the custom-property schema, repo assignments, and two rulesets (baseline + critical-tier extras). Apply order: properties -> values -> rulesets. Rollback commands included. Deferred to follow-up: dotnet-ci.yml (only 1 consumer), cpp-ci.yml (2 consumers); required.yml will need a small edit when those land. --- .github/workflows/docker-publish.yml | 124 ++++++++++++++ .github/workflows/node-ci.yml | 122 ++++++++++++++ .github/workflows/python-ci.yml | 154 +++++++++++++++++ .github/workflows/required.yml | 140 ++++++++++++++++ .github/workflows/rust-ci.yml | 161 ++++++++++++++++++ ops/governance-payloads.md | 236 +++++++++++++++++++++++++++ 6 files changed, 937 insertions(+) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/node-ci.yml create mode 100644 .github/workflows/python-ci.yml create mode 100644 .github/workflows/required.yml create mode 100644 .github/workflows/rust-ci.yml create mode 100644 ops/governance-payloads.md diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..eec42f6 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,124 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable container build + publish + provenance workflow. +# Builds via buildx, pushes to ghcr.io by default, and produces +# an SLSA build-provenance attestation (sigstore-backed) for the +# image digest. Callable from any resq-software/* repo. +# +# SHA-pinned actions with trailing `# ` for Dependabot. + +name: docker-publish + +on: + workflow_call: + inputs: + image: + description: Image name (without registry), e.g. "resq-software/mcp". + type: string + required: true + registry: + type: string + required: false + default: "ghcr.io" + context: + description: Docker build context path. + type: string + required: false + default: "." + dockerfile: + description: Path to Dockerfile relative to repo root. + type: string + required: false + default: "Dockerfile" + platforms: + description: Comma-separated target platforms. + type: string + required: false + default: "linux/amd64,linux/arm64" + tags: + description: | + Newline-separated image tags to push (full refs). Example: + ghcr.io/resq-software/mcp:latest + ghcr.io/resq-software/mcp:${{ version }} + type: string + required: true + build-args: + description: Newline-separated docker build args (KEY=VALUE). + type: string + required: false + default: "" + push: + type: boolean + required: false + default: true + attest: + description: Emit SLSA build-provenance attestation. + type: boolean + required: false + default: true + ref: + description: Git ref to checkout (defaults to workflow ref). + type: string + required: false + default: "" + outputs: + image-digest: + description: sha256 digest of the pushed image manifest. + value: ${{ jobs.publish.outputs.digest }} + +permissions: + contents: read + +jobs: + publish: + name: Build, push, attest + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write # push to GHCR + id-token: write # OIDC for keyless sigstore signing + attestations: write # actions/attest-build-provenance + outputs: + digest: ${{ steps.build.outputs.digest }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref || github.ref }} + + - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + + - name: Log in to registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.registry }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + tags: ${{ inputs.tags }} + build-args: ${{ inputs.build-args }} + push: ${{ inputs.push }} + provenance: mode=max + sbom: true + + - if: ${{ inputs.attest && inputs.push }} + name: Attest build provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ inputs.registry }}/${{ inputs.image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml new file mode 100644 index 0000000..7f181b4 --- /dev/null +++ b/.github/workflows/node-ci.yml @@ -0,0 +1,122 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable Node/TypeScript CI — supports bun (default), npm, pnpm, yarn. +# Generic; doesn't enforce a specific test framework or linter. Pairs +# with security-scan.yml. SHA-pinned actions with trailing `# `. + +name: node-ci + +on: + workflow_call: + inputs: + package-manager: + description: bun | npm | pnpm | yarn. + type: string + required: false + default: "bun" + node-version: + type: string + required: false + default: "22" + bun-version: + type: string + required: false + default: "latest" + working-directory: + type: string + required: false + default: "." + install-cmd: + description: Override install command. Empty = pm default (e.g., `bun install`). + type: string + required: false + default: "" + lint-cmd: + description: Lint script (empty to skip). + type: string + required: false + default: "" + typecheck-cmd: + description: Typecheck script (empty to skip). + type: string + required: false + default: "" + test-cmd: + description: Test script (empty to skip). + type: string + required: false + default: "" + build-cmd: + description: Build script (empty to skip). + type: string + required: false + default: "" + timeout-minutes: + type: number + required: false + default: 15 + +permissions: + contents: read + +jobs: + ci: + name: Node CI + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - if: ${{ inputs.package-manager == 'bun' }} + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2 + with: + bun-version: ${{ inputs.bun-version }} + + - if: ${{ inputs.package-manager != 'bun' }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager == 'yarn' && 'yarn' || inputs.package-manager == 'pnpm' && 'pnpm' || 'npm' }} + + - if: ${{ inputs.package-manager == 'pnpm' }} + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + with: + run_install: false + + - name: Install + run: | + if [ -n "${{ inputs.install-cmd }}" ]; then + ${{ inputs.install-cmd }} + else + case "${{ inputs.package-manager }}" in + bun) bun install --frozen-lockfile ;; + npm) npm ci ;; + pnpm) pnpm install --frozen-lockfile ;; + yarn) yarn install --immutable ;; + esac + fi + + - if: ${{ inputs.lint-cmd != '' }} + name: Lint + run: ${{ inputs.lint-cmd }} + + - if: ${{ inputs.typecheck-cmd != '' }} + name: Typecheck + run: ${{ inputs.typecheck-cmd }} + + - if: ${{ inputs.build-cmd != '' }} + name: Build + run: ${{ inputs.build-cmd }} + + - if: ${{ inputs.test-cmd != '' }} + name: Test + run: ${{ inputs.test-cmd }} diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..7b6fc9a --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,154 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable Python CI — uv + ruff + mypy + pytest. Callable from any +# resq-software/* repo. Pairs with security-scan.yml. SHA-pinned +# actions with trailing `# ` so Dependabot can refresh them. + +name: python-ci + +on: + workflow_call: + inputs: + python-versions: + description: JSON array of Python versions for the test matrix. + type: string + required: false + default: '["3.11","3.12","3.13"]' + default-python: + description: Python version used for lint, typecheck, build. + type: string + required: false + default: "3.13" + working-directory: + description: Package directory containing pyproject.toml. + type: string + required: false + default: "." + run-lint: + type: boolean + required: false + default: true + run-typecheck: + type: boolean + required: false + default: true + run-test: + type: boolean + required: false + default: true + run-build: + type: boolean + required: false + default: true + test-flags: + description: Extra flags passed to pytest. + type: string + required: false + default: "-v" + mypy-targets: + description: mypy target path(s). + type: string + required: false + default: "src/" + ruff-targets: + description: ruff target path(s). + type: string + required: false + default: "src/ tests/" + timeout-minutes: + type: number + required: false + default: 15 + +permissions: + contents: read + +jobs: + lint: + if: ${{ inputs.run-lint }} + name: Lint + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + python-version: ${{ inputs.default-python }} + enable-cache: true + - run: uv sync + - run: uv run ruff check ${{ inputs.ruff-targets }} + - run: uv run ruff format --check ${{ inputs.ruff-targets }} + + typecheck: + if: ${{ inputs.run-typecheck }} + name: Type Check + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + python-version: ${{ inputs.default-python }} + enable-cache: true + - run: uv sync + - run: uv run mypy ${{ inputs.mypy-targets }} + + test: + if: ${{ inputs.run-test }} + name: Test (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJSON(inputs.python-versions) }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + - run: uv sync + - run: uv run pytest tests/ ${{ inputs.test-flags }} + + build: + if: ${{ inputs.run-build }} + name: Build wheel + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + python-version: ${{ inputs.default-python }} + enable-cache: true + - run: uv build diff --git a/.github/workflows/required.yml b/.github/workflows/required.yml new file mode 100644 index 0000000..9f68e2d --- /dev/null +++ b/.github/workflows/required.yml @@ -0,0 +1,140 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable CI aggregator. Each consumer repo calls this with a `lang` +# input matching its `lang` custom-repo-property value. The workflow +# dispatches to the matching language CI (rust|python|node|polyglot) +# and always runs the org-wide security scan in parallel. The final +# `required` job needs all upstream jobs — Ruleset A requires the +# `required` status-check context. +# +# SHA-pinned actions with trailing `# ` for Dependabot. + +name: required + +on: + workflow_call: + inputs: + lang: + description: One of rust|python|node|polyglot. + type: string + required: true + working-directory: + type: string + required: false + default: "." + codeql-languages: + description: JSON array for security-scan CodeQL input. + type: string + required: false + default: "[]" + enable-semgrep: + type: boolean + required: false + default: false + enable-gitleaks: + type: boolean + required: false + default: false + node-package-manager: + type: string + required: false + default: "bun" + node-lint-cmd: + type: string + required: false + default: "" + node-typecheck-cmd: + type: string + required: false + default: "" + node-test-cmd: + type: string + required: false + default: "" + node-build-cmd: + type: string + required: false + default: "" + rust-run-coverage: + type: boolean + required: false + default: false + rust-run-deny: + type: boolean + required: false + default: false + python-versions: + type: string + required: false + default: '["3.11","3.12","3.13"]' + +permissions: + contents: read + security-events: write + pull-requests: read + +jobs: + security: + name: Security + uses: ./.github/workflows/security-scan.yml + with: + languages: ${{ inputs.codeql-languages }} + enable-semgrep: ${{ inputs.enable-semgrep }} + enable-gitleaks: ${{ inputs.enable-gitleaks }} + secrets: inherit + + rust: + if: ${{ inputs.lang == 'rust' || inputs.lang == 'polyglot' }} + name: Rust CI + uses: ./.github/workflows/rust-ci.yml + with: + working-directory: ${{ inputs.working-directory }} + run-coverage: ${{ inputs.rust-run-coverage }} + run-deny: ${{ inputs.rust-run-deny }} + + python: + if: ${{ inputs.lang == 'python' || inputs.lang == 'polyglot' }} + name: Python CI + uses: ./.github/workflows/python-ci.yml + with: + working-directory: ${{ inputs.working-directory }} + python-versions: ${{ inputs.python-versions }} + + node: + if: ${{ inputs.lang == 'node' || inputs.lang == 'polyglot' }} + name: Node CI + uses: ./.github/workflows/node-ci.yml + with: + working-directory: ${{ inputs.working-directory }} + package-manager: ${{ inputs.node-package-manager }} + lint-cmd: ${{ inputs.node-lint-cmd }} + typecheck-cmd: ${{ inputs.node-typecheck-cmd }} + test-cmd: ${{ inputs.node-test-cmd }} + build-cmd: ${{ inputs.node-build-cmd }} + + # `required` is the single status-check context consumed by Ruleset A. + # Passes iff every dispatched language job AND security passed. + # Skipped jobs (lang-mismatch `if:` gates) are treated as success. + required: + name: required + needs: [security, rust, python, node] + if: always() + runs-on: ubuntu-latest + steps: + - name: Evaluate upstream results + env: + SECURITY_RESULT: ${{ needs.security.result }} + RUST_RESULT: ${{ needs.rust.result }} + PYTHON_RESULT: ${{ needs.python.result }} + NODE_RESULT: ${{ needs.node.result }} + run: | + set -eu + fail=0 + for r in "$SECURITY_RESULT" "$RUST_RESULT" "$PYTHON_RESULT" "$NODE_RESULT"; do + case "$r" in + success|skipped|"") ;; + *) echo "::error::Upstream job returned: $r"; fail=1 ;; + esac + done + exit "$fail" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..2790dc8 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,161 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable Rust CI — callable from any resq-software/* repo. +# Pairs with security-scan.yml (which should run alongside this in +# consumer repos). SHA-pinned actions with trailing `# ` so +# Dependabot can still propose updates. + +name: rust-ci + +on: + workflow_call: + inputs: + toolchain: + description: Rust toolchain (stable|nightly|1.83.0|etc). + type: string + required: false + default: stable + components: + description: Comma-separated rustup components. + type: string + required: false + default: rustfmt,clippy + working-directory: + description: Cargo workspace root (defaults to repo root). + type: string + required: false + default: "." + run-fmt: + type: boolean + required: false + default: true + run-clippy: + type: boolean + required: false + default: true + run-test: + type: boolean + required: false + default: true + run-deny: + description: Run cargo-deny (requires deny.toml in working-directory). + type: boolean + required: false + default: false + run-coverage: + description: Run cargo-llvm-cov and upload to Codecov. + type: boolean + required: false + default: false + clippy-flags: + description: Extra flags passed to cargo clippy. + type: string + required: false + default: "--all-targets --all-features -- -D warnings" + test-flags: + description: Extra flags passed to cargo test. + type: string + required: false + default: "--all-features --workspace" + timeout-minutes: + type: number + required: false + default: 20 + +permissions: + contents: read + +jobs: + fmt: + if: ${{ inputs.run-fmt }} + name: Format + runs-on: ubuntu-latest + timeout-minutes: 5 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: ${{ inputs.toolchain }} + components: rustfmt + - run: cargo fmt --all --check + + clippy: + if: ${{ inputs.run-clippy }} + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: ${{ inputs.toolchain }} + components: ${{ inputs.components }} + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - run: cargo clippy ${{ inputs.clippy-flags }} + + test: + if: ${{ inputs.run-test }} + name: Test + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: ${{ inputs.toolchain }} + components: ${{ inputs.components }} + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - if: ${{ !inputs.run-coverage }} + run: cargo test ${{ inputs.test-flags }} + - if: ${{ inputs.run-coverage }} + uses: taiki-e/install-action@a416ddeedbd748e9e3f037655f22dc2ee45c17ae # cargo-llvm-cov + with: + tool: cargo-llvm-cov + - if: ${{ inputs.run-coverage }} + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + - if: ${{ inputs.run-coverage && always() }} + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + files: ${{ inputs.working-directory }}/lcov.info + fail_ci_if_error: false + + deny: + if: ${{ inputs.run-deny }} + name: cargo-deny + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: EmbarkStudios/cargo-deny-action@34899fc7ba81ca6268d5947a7a16b4649013fea1 # v2 + with: + manifest-path: ${{ inputs.working-directory }}/Cargo.toml + arguments: --all-features diff --git a/ops/governance-payloads.md b/ops/governance-payloads.md new file mode 100644 index 0000000..73755e6 --- /dev/null +++ b/ops/governance-payloads.md @@ -0,0 +1,236 @@ +# Governance payloads + +API payloads for custom repository properties and org rulesets. Apply via +`gh api` after review. Order: **properties → values → rulesets** (rulesets +reference property values, so properties must be defined first). + +> **Warning.** Ruleset A enforces `require_code_owner_review`. Any repo +> without `CODEOWNERS` will block every PR until one lands. `resq-proto` +> currently lacks `CODEOWNERS` — either ship one or exclude `resq-proto` +> from Ruleset A's `conditions.repository_name` during a bake period. + +--- + +## 1. Custom property schema + +Endpoint: `PATCH /orgs/resq-software/properties/schema` with body: + +```json +{ + "properties": [ + { + "property_name": "domain", + "value_type": "single_select", + "required": true, + "default_value": "tooling", + "description": "What the repo serves.", + "allowed_values": ["drone", "backend", "frontend", "sdk", "ops", "infra", "tooling"] + }, + { + "property_name": "tier", + "value_type": "single_select", + "required": true, + "default_value": "supporting", + "description": "Governance strictness target.", + "allowed_values": ["critical", "supporting", "experimental"] + }, + { + "property_name": "lang", + "value_type": "single_select", + "required": true, + "default_value": "polyglot", + "description": "Which reusable CI to wire.", + "allowed_values": ["rust", "ts", "py", "cpp", "cs", "proto", "polyglot"] + } + ] +} +``` + +Apply: + +```sh +gh api --method PATCH \ + -H "Accept: application/vnd.github+json" \ + /orgs/resq-software/properties/schema \ + --input ops/properties-schema.json +``` + +Extract the JSON block above into `ops/properties-schema.json` before running — `gh api --input` reads a file. + +--- + +## 2. Property value assignments + +Endpoint: `PATCH /orgs/resq-software/properties/values`. One entry per repo. + +```json +{ + "properties": [ + { "repository_name": "crates", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "rust"} ] }, + { "repository_name": "dev", "properties": [ {"property_name": "domain", "value": "tooling"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "dotnet-sdk", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cs"} ] }, + { "repository_name": "landing", "properties": [ {"property_name": "domain", "value": "frontend"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "programs", "properties": [ {"property_name": "domain", "value": "drone"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "rust"} ] }, + { "repository_name": "npm", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "pypi", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "py"} ] }, + { "repository_name": "resQ", "properties": [ {"property_name": "domain", "value": "backend"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "polyglot"} ] }, + { "repository_name": "vcpkg", "properties": [ {"property_name": "domain", "value": "infra"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cpp"} ] }, + { "repository_name": ".github", "properties": [ {"property_name": "domain", "value": "ops"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "polyglot"} ] }, + { "repository_name": "docs", "properties": [ {"property_name": "domain", "value": "ops"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "viz", "properties": [ {"property_name": "domain", "value": "frontend"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "resq-proto", "properties": [ {"property_name": "domain", "value": "backend"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "proto"} ] }, + { "repository_name": "ardupilot", "properties": [ {"property_name": "domain", "value": "drone"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cpp"} ] } + ] +} +``` + +Apply: + +```sh +gh api --method PATCH \ + -H "Accept: application/vnd.github+json" \ + /orgs/resq-software/properties/values \ + --input ops/properties-values.json +``` + +--- + +## 3. Ruleset A — default-branch baseline (all repos) + +```json +{ + "name": "default-branch-baseline", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }, + "repository_name": { "include": ["*"], "exclude": [], "protected": true } + }, + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" } + ], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "required", "integration_id": null } + ] + } + } + ] +} +``` + +`bypass_actors.actor_id: 5` = built-in `RepositoryRole: admin`. Adjust or drop if you want zero bypass. + +Apply: + +```sh +gh api --method POST \ + -H "Accept: application/vnd.github+json" \ + /orgs/resq-software/rulesets \ + --input ops/ruleset-a-baseline.json +``` + +--- + +## 4. Ruleset B — critical-tier extras + +Adds stricter rules to repos where `tier=critical`. Composes with Ruleset A. + +```json +{ + "name": "critical-tier-extras", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }, + "repository_property": { + "include": [ + { "name": "tier", "property_values": ["critical"], "source": "custom" } + ], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "required_deployments", + "parameters": { + "required_deployment_environments": ["production"] + } + } + ] +} +``` + +Apply: + +```sh +gh api --method POST \ + -H "Accept: application/vnd.github+json" \ + /orgs/resq-software/rulesets \ + --input ops/ruleset-b-critical.json +``` + +--- + +## 5. Verification (read-only) + +```sh +gh api /orgs/resq-software/properties/schema +gh api /orgs/resq-software/properties/values +gh api /orgs/resq-software/rulesets +gh api /repos/resq-software/pypi/rules/branches/main +``` + +--- + +## 6. Rollback + +```sh +# List with IDs +gh api /orgs/resq-software/rulesets --jq '.[] | {id, name, enforcement}' + +# Soft-disable +gh api --method PUT /orgs/resq-software/rulesets/ -f enforcement=disabled + +# Hard delete +gh api --method DELETE /orgs/resq-software/rulesets/ + +# Remove a property from the schema +gh api --method DELETE /orgs/resq-software/properties/schema/ +``` + +--- + +## 7. Extract to standalone files before applying + +Before `gh api --input`, split the embedded JSON into its own files: + +``` +ops/ + properties-schema.json # JSON from §1 + properties-values.json # JSON from §2 + ruleset-a-baseline.json # JSON from §3 + ruleset-b-critical.json # JSON from §4 +``` + +Each standalone file contains ONLY the JSON body (no Markdown, no code +fences). This Markdown document is the source-of-truth for intent; the +split files are what `gh api --input` consumes. From 6410acba18c9fc79949643abd48f0a2d43994c62 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 01:22:27 -0400 Subject: [PATCH 2/9] feat(workflows): add dotnet-ci + cpp-ci reusables; extend required.yml dispatch Adds the two deferred reusable workflows: - dotnet-ci.yml: restore, build, format check, test. Configurable dotnet-version (default 9.0.x), solution path, working-directory. - cpp-ci.yml: CMake configure + build + test matrix across ubuntu/macos/windows (configurable). Harden-runner on Linux only (step-security action is Linux-native). Extends required.yml to dispatch the two new languages, and adds proto handling (security-only, no language build dispatched). New inputs: dotnet-version, dotnet-solution, cpp-os-list, cpp-source-dir, cpp-cmake-flags. After this lands, these placeholder consumer PRs can be hardened to call real CI: - resq-software/dotnet-sdk#36 (dotnet) - resq-software/viz#4 (dotnet) - resq-software/vcpkg#6 (cpp) - resq-software/ardupilot#1 (cpp) --- .github/workflows/cpp-ci.yml | 70 +++++++++++++++++++++ .github/workflows/dotnet-ci.yml | 106 ++++++++++++++++++++++++++++++++ .github/workflows/required.yml | 63 +++++++++++++++---- 3 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/cpp-ci.yml create mode 100644 .github/workflows/dotnet-ci.yml diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml new file mode 100644 index 0000000..6ed2eac --- /dev/null +++ b/.github/workflows/cpp-ci.yml @@ -0,0 +1,70 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable C++ CI — CMake configure + build + test. Matrix over OS. +# Callable from any resq-software/* repo. Pairs with security-scan.yml. +# SHA-pinned actions with trailing `# `. + +name: cpp-ci + +on: + workflow_call: + inputs: + os-list: + description: JSON array of runner OS labels. + type: string + required: false + default: '["ubuntu-latest","macos-latest","windows-latest"]' + source-dir: + description: Path containing CMakeLists.txt (or subproject path). + type: string + required: false + default: "." + build-dir: + type: string + required: false + default: "build" + cmake-flags: + description: Extra flags passed to `cmake -B `. + type: string + required: false + default: "" + build-config: + type: string + required: false + default: "Debug" + run-test: + type: boolean + required: false + default: true + timeout-minutes: + type: number + required: false + default: 30 + +permissions: + contents: read + +jobs: + build-test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: ${{ inputs.timeout-minutes }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.os-list) }} + steps: + - name: Harden Runner + if: runner.os == 'Linux' + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure + run: cmake -B ${{ inputs.build-dir }} ${{ inputs.cmake-flags }} ${{ inputs.source-dir }} + - name: Build + run: cmake --build ${{ inputs.build-dir }} --config ${{ inputs.build-config }} + - if: ${{ inputs.run-test }} + name: Test + run: ctest --test-dir ${{ inputs.build-dir }} --output-on-failure -C ${{ inputs.build-config }} diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml new file mode 100644 index 0000000..43d45d3 --- /dev/null +++ b/.github/workflows/dotnet-ci.yml @@ -0,0 +1,106 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable .NET CI — restore, build, test, format check. Callable from +# any resq-software/* repo with a .sln or csproj tree. Pairs with +# security-scan.yml. SHA-pinned actions with trailing `# `. + +name: dotnet-ci + +on: + workflow_call: + inputs: + dotnet-version: + description: .NET SDK version (e.g. "9.0.x", or "global.json"). + type: string + required: false + default: "9.0.x" + solution: + description: Path to .sln or csproj (defaults to repo root auto-detect). + type: string + required: false + default: "" + working-directory: + type: string + required: false + default: "." + configuration: + type: string + required: false + default: "Release" + run-format-check: + type: boolean + required: false + default: true + run-test: + type: boolean + required: false + default: true + timeout-minutes: + type: number + required: false + default: 20 + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4 + with: + dotnet-version: ${{ inputs.dotnet-version }} + - name: Restore + run: dotnet restore ${{ inputs.solution }} + - name: Build + run: dotnet build ${{ inputs.solution }} -c ${{ inputs.configuration }} --no-restore + + format: + if: ${{ inputs.run-format-check }} + name: Format check + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4 + with: + dotnet-version: ${{ inputs.dotnet-version }} + - run: dotnet format ${{ inputs.solution }} --verify-no-changes --severity warn + + test: + if: ${{ inputs.run-test }} + name: Test + needs: build + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4 + with: + dotnet-version: ${{ inputs.dotnet-version }} + - run: dotnet test ${{ inputs.solution }} -c ${{ inputs.configuration }} --verbosity normal diff --git a/.github/workflows/required.yml b/.github/workflows/required.yml index 9f68e2d..13c38c1 100644 --- a/.github/workflows/required.yml +++ b/.github/workflows/required.yml @@ -3,12 +3,14 @@ # # Reusable CI aggregator. Each consumer repo calls this with a `lang` # input matching its `lang` custom-repo-property value. The workflow -# dispatches to the matching language CI (rust|python|node|polyglot) -# and always runs the org-wide security scan in parallel. The final -# `required` job needs all upstream jobs — Ruleset A requires the -# `required` status-check context. +# dispatches to the matching language CI (rust|python|node|dotnet|cpp| +# proto|polyglot) and always runs the org-wide security scan in +# parallel. The final `required` job needs all upstream jobs — Ruleset +# A requires the `required` status-check context. # -# SHA-pinned actions with trailing `# ` for Dependabot. +# For `lang: proto` repos (e.g. resq-proto), only security scanning +# runs — no language build is dispatched. Add a buf lint / breaking- +# change workflow locally if you want extra checks. name: required @@ -16,7 +18,7 @@ on: workflow_call: inputs: lang: - description: One of rust|python|node|polyglot. + description: One of rust|python|node|dotnet|cpp|proto|polyglot. type: string required: true working-directory: @@ -24,7 +26,6 @@ on: required: false default: "." codeql-languages: - description: JSON array for security-scan CodeQL input. type: string required: false default: "[]" @@ -68,6 +69,26 @@ on: type: string required: false default: '["3.11","3.12","3.13"]' + dotnet-version: + type: string + required: false + default: "9.0.x" + dotnet-solution: + type: string + required: false + default: "" + cpp-os-list: + type: string + required: false + default: '["ubuntu-latest"]' + cpp-source-dir: + type: string + required: false + default: "." + cpp-cmake-flags: + type: string + required: false + default: "" permissions: contents: read @@ -113,12 +134,30 @@ jobs: test-cmd: ${{ inputs.node-test-cmd }} build-cmd: ${{ inputs.node-build-cmd }} + dotnet: + if: ${{ inputs.lang == 'dotnet' || inputs.lang == 'polyglot' }} + name: .NET CI + uses: ./.github/workflows/dotnet-ci.yml + with: + dotnet-version: ${{ inputs.dotnet-version }} + solution: ${{ inputs.dotnet-solution }} + working-directory: ${{ inputs.working-directory }} + + cpp: + if: ${{ inputs.lang == 'cpp' || inputs.lang == 'polyglot' }} + name: C++ CI + uses: ./.github/workflows/cpp-ci.yml + with: + os-list: ${{ inputs.cpp-os-list }} + source-dir: ${{ inputs.cpp-source-dir }} + cmake-flags: ${{ inputs.cpp-cmake-flags }} + + # proto repos get security-only; nothing to dispatch here beyond that. + # `required` is the single status-check context consumed by Ruleset A. - # Passes iff every dispatched language job AND security passed. - # Skipped jobs (lang-mismatch `if:` gates) are treated as success. required: name: required - needs: [security, rust, python, node] + needs: [security, rust, python, node, dotnet, cpp] if: always() runs-on: ubuntu-latest steps: @@ -128,10 +167,12 @@ jobs: RUST_RESULT: ${{ needs.rust.result }} PYTHON_RESULT: ${{ needs.python.result }} NODE_RESULT: ${{ needs.node.result }} + DOTNET_RESULT: ${{ needs.dotnet.result }} + CPP_RESULT: ${{ needs.cpp.result }} run: | set -eu fail=0 - for r in "$SECURITY_RESULT" "$RUST_RESULT" "$PYTHON_RESULT" "$NODE_RESULT"; do + for r in "$SECURITY_RESULT" "$RUST_RESULT" "$PYTHON_RESULT" "$NODE_RESULT" "$DOTNET_RESULT" "$CPP_RESULT"; do case "$r" in success|skipped|"") ;; *) echo "::error::Upstream job returned: $r"; fail=1 ;; From de56ed8391d04544230331e71b4a06ecddec257d Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 04:48:52 -0400 Subject: [PATCH 3/9] fix(workflows): mitigate script injection; fix pnpm ordering; validate lang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #12 from CodeQL, CodeRabbit, and gemini-code-assist: Script-injection mitigations (CodeQL #1-15, #22-25; CodeRabbit): - node-ci.yml: forward install/lint/typecheck/build/test/package-manager via env: - python-ci.yml: forward ruff/mypy/test targets and flags via env: - rust-ci.yml: forward clippy-flags and test-flags via env: - cpp-ci.yml: forward source-dir, build-dir, cmake-flags via env: - docker-publish.yml: image-digest output doc-fixed (only populated when push:true) All affected steps now use `sh -c "$VAR"` rather than direct `${{ ... }}` interpolation, closing the template-time-to-shell expansion path. node-ci.yml critical ordering fix (CodeRabbit): - pnpm/action-setup now runs BEFORE actions/setup-node with cache: 'pnpm'; required because setup-node's pnpm cache detection resolves the pnpm binary. required.yml (CodeRabbit): - Added validate-lang gate that fails fast on unknown lang values. Prior to this, a typo like `pyhton` silently skipped every language job and `required` still passed. validate-lang is now a `needs:` dependency of every other job + the final `required` evaluator. python-ci.yml (CodeRabbit): - Added `test-paths` input (default empty = pytest discovers via pyproject.toml). Previous hardcoded `tests/` didn't match src-layout or discover-by-config projects. ops/governance-payloads.md (gemini-code-assist): - Rollback command for rulesets: PUT was stripping the resource because only `enforcement` was sent. Replaced with `gh api GET | jq .enforcement="disabled" | gh api PUT --input -`. Still open (not fixed here, requires org discussion): - Ruleset A `required_approving_review_count: 0` — gemini notes that without CODEOWNERS matching, PRs can merge with zero reviews. Current solo-dev intent; bump when team grows. --- .github/workflows/cpp-ci.yml | 28 +++++++++++----- .github/workflows/docker-publish.yml | 29 ++++++++-------- .github/workflows/node-ci.yml | 49 ++++++++++++++++++---------- .github/workflows/python-ci.yml | 38 ++++++++++++++------- .github/workflows/required.yml | 38 +++++++++++++++++---- .github/workflows/rust-ci.yml | 25 +++++++------- ops/governance-payloads.md | 7 ++-- 7 files changed, 141 insertions(+), 73 deletions(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 6ed2eac..78f87b7 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -2,8 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 # # Reusable C++ CI — CMake configure + build + test. Matrix over OS. -# Callable from any resq-software/* repo. Pairs with security-scan.yml. -# SHA-pinned actions with trailing `# `. +# Pairs with security-scan.yml. +# +# Security: caller-controlled path/flag inputs are forwarded through +# `env:` and referenced as `"$VAR"` to prevent shell injection. name: cpp-ci @@ -11,12 +13,10 @@ on: workflow_call: inputs: os-list: - description: JSON array of runner OS labels. type: string required: false default: '["ubuntu-latest","macos-latest","windows-latest"]' source-dir: - description: Path containing CMakeLists.txt (or subproject path). type: string required: false default: "." @@ -25,7 +25,6 @@ on: required: false default: "build" cmake-flags: - description: Extra flags passed to `cmake -B `. type: string required: false default: "" @@ -62,9 +61,22 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure - run: cmake -B ${{ inputs.build-dir }} ${{ inputs.cmake-flags }} ${{ inputs.source-dir }} + shell: bash + env: + SRC: ${{ inputs.source-dir }} + BUILD: ${{ inputs.build-dir }} + FLAGS: ${{ inputs.cmake-flags }} + run: cmake -B "$BUILD" $FLAGS "$SRC" - name: Build - run: cmake --build ${{ inputs.build-dir }} --config ${{ inputs.build-config }} + shell: bash + env: + BUILD: ${{ inputs.build-dir }} + CONFIG: ${{ inputs.build-config }} + run: cmake --build "$BUILD" --config "$CONFIG" - if: ${{ inputs.run-test }} name: Test - run: ctest --test-dir ${{ inputs.build-dir }} --output-on-failure -C ${{ inputs.build-config }} + shell: bash + env: + BUILD: ${{ inputs.build-dir }} + CONFIG: ${{ inputs.build-config }} + run: ctest --test-dir "$BUILD" --output-on-failure -C "$CONFIG" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index eec42f6..36fd2d6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,9 +4,11 @@ # Reusable container build + publish + provenance workflow. # Builds via buildx, pushes to ghcr.io by default, and produces # an SLSA build-provenance attestation (sigstore-backed) for the -# image digest. Callable from any resq-software/* repo. +# image digest. # -# SHA-pinned actions with trailing `# ` for Dependabot. +# Security: caller-controlled path inputs are forwarded through +# `env:` (the build action itself accepts them as `with:` parameters, +# which is safe — but the ref passthrough is constrained via env). name: docker-publish @@ -22,29 +24,22 @@ on: required: false default: "ghcr.io" context: - description: Docker build context path. type: string required: false default: "." dockerfile: - description: Path to Dockerfile relative to repo root. type: string required: false default: "Dockerfile" platforms: - description: Comma-separated target platforms. type: string required: false default: "linux/amd64,linux/arm64" tags: - description: | - Newline-separated image tags to push (full refs). Example: - ghcr.io/resq-software/mcp:latest - ghcr.io/resq-software/mcp:${{ version }} + description: Newline-separated image tags to push (full refs). type: string required: true build-args: - description: Newline-separated docker build args (KEY=VALUE). type: string required: false default: "" @@ -53,7 +48,7 @@ on: required: false default: true attest: - description: Emit SLSA build-provenance attestation. + description: Emit SLSA build-provenance attestation (only runs when push is true). type: boolean required: false default: true @@ -64,7 +59,11 @@ on: default: "" outputs: image-digest: - description: sha256 digest of the pushed image manifest. + description: > + sha256 digest of the pushed image manifest. Populated ONLY when + `push: true` — the underlying docker/build-push-action emits + digest only for pushed images. Will be empty string if push was + skipped. value: ${{ jobs.publish.outputs.digest }} permissions: @@ -77,9 +76,9 @@ jobs: timeout-minutes: 30 permissions: contents: read - packages: write # push to GHCR - id-token: write # OIDC for keyless sigstore signing - attestations: write # actions/attest-build-provenance + packages: write + id-token: write + attestations: write outputs: digest: ${{ steps.build.outputs.digest }} steps: diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 7f181b4..d1a13c2 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -4,6 +4,10 @@ # Reusable Node/TypeScript CI — supports bun (default), npm, pnpm, yarn. # Generic; doesn't enforce a specific test framework or linter. Pairs # with security-scan.yml. SHA-pinned actions with trailing `# `. +# +# Security: all workflow_call inputs that reach `run:` are forwarded +# through `env:` and referenced as `"$VAR"` to prevent template-time +# expansion of potentially caller-controlled strings into the shell. name: node-ci @@ -28,27 +32,23 @@ on: required: false default: "." install-cmd: - description: Override install command. Empty = pm default (e.g., `bun install`). + description: Override install command. Empty = pm default. type: string required: false default: "" lint-cmd: - description: Lint script (empty to skip). type: string required: false default: "" typecheck-cmd: - description: Typecheck script (empty to skip). type: string required: false default: "" test-cmd: - description: Test script (empty to skip). type: string required: false default: "" build-cmd: - description: Build script (empty to skip). type: string required: false default: "" @@ -76,6 +76,13 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # pnpm MUST be installed before setup-node when cache: 'pnpm' is used, + # because setup-node's pnpm cache detection resolves the pnpm binary. + - if: ${{ inputs.package-manager == 'pnpm' }} + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + with: + run_install: false + - if: ${{ inputs.package-manager == 'bun' }} uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2 with: @@ -87,36 +94,44 @@ jobs: node-version: ${{ inputs.node-version }} cache: ${{ inputs.package-manager == 'yarn' && 'yarn' || inputs.package-manager == 'pnpm' && 'pnpm' || 'npm' }} - - if: ${{ inputs.package-manager == 'pnpm' }} - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 - with: - run_install: false - - name: Install + env: + PM: ${{ inputs.package-manager }} + INSTALL_CMD: ${{ inputs.install-cmd }} run: | - if [ -n "${{ inputs.install-cmd }}" ]; then - ${{ inputs.install-cmd }} + set -eu + if [ -n "$INSTALL_CMD" ]; then + sh -c "$INSTALL_CMD" else - case "${{ inputs.package-manager }}" in + case "$PM" in bun) bun install --frozen-lockfile ;; npm) npm ci ;; pnpm) pnpm install --frozen-lockfile ;; yarn) yarn install --immutable ;; + *) echo "::error::Unknown package-manager: $PM" >&2; exit 2 ;; esac fi - if: ${{ inputs.lint-cmd != '' }} name: Lint - run: ${{ inputs.lint-cmd }} + env: + CMD: ${{ inputs.lint-cmd }} + run: sh -c "$CMD" - if: ${{ inputs.typecheck-cmd != '' }} name: Typecheck - run: ${{ inputs.typecheck-cmd }} + env: + CMD: ${{ inputs.typecheck-cmd }} + run: sh -c "$CMD" - if: ${{ inputs.build-cmd != '' }} name: Build - run: ${{ inputs.build-cmd }} + env: + CMD: ${{ inputs.build-cmd }} + run: sh -c "$CMD" - if: ${{ inputs.test-cmd != '' }} name: Test - run: ${{ inputs.test-cmd }} + env: + CMD: ${{ inputs.test-cmd }} + run: sh -c "$CMD" diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 7b6fc9a..3d783ea 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -2,8 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 # # Reusable Python CI — uv + ruff + mypy + pytest. Callable from any -# resq-software/* repo. Pairs with security-scan.yml. SHA-pinned -# actions with trailing `# ` so Dependabot can refresh them. +# resq-software/* repo. Pairs with security-scan.yml. +# +# Security: caller-controlled string inputs that reach `run:` are +# forwarded through `env:` and referenced as `"$VAR"`. name: python-ci @@ -11,17 +13,14 @@ on: workflow_call: inputs: python-versions: - description: JSON array of Python versions for the test matrix. type: string required: false default: '["3.11","3.12","3.13"]' default-python: - description: Python version used for lint, typecheck, build. type: string required: false default: "3.13" working-directory: - description: Package directory containing pyproject.toml. type: string required: false default: "." @@ -41,18 +40,20 @@ on: type: boolean required: false default: true + test-paths: + description: Space-separated test paths. Empty = pytest discovers via pyproject.toml. + type: string + required: false + default: "" test-flags: - description: Extra flags passed to pytest. type: string required: false default: "-v" mypy-targets: - description: mypy target path(s). type: string required: false default: "src/" ruff-targets: - description: ruff target path(s). type: string required: false default: "src/ tests/" @@ -84,8 +85,14 @@ jobs: python-version: ${{ inputs.default-python }} enable-cache: true - run: uv sync - - run: uv run ruff check ${{ inputs.ruff-targets }} - - run: uv run ruff format --check ${{ inputs.ruff-targets }} + - name: ruff check + env: + TARGETS: ${{ inputs.ruff-targets }} + run: sh -c "uv run ruff check $TARGETS" + - name: ruff format check + env: + TARGETS: ${{ inputs.ruff-targets }} + run: sh -c "uv run ruff format --check $TARGETS" typecheck: if: ${{ inputs.run-typecheck }} @@ -106,7 +113,10 @@ jobs: python-version: ${{ inputs.default-python }} enable-cache: true - run: uv sync - - run: uv run mypy ${{ inputs.mypy-targets }} + - name: mypy + env: + TARGETS: ${{ inputs.mypy-targets }} + run: sh -c "uv run mypy $TARGETS" test: if: ${{ inputs.run-test }} @@ -131,7 +141,11 @@ jobs: python-version: ${{ matrix.python-version }} enable-cache: true - run: uv sync - - run: uv run pytest tests/ ${{ inputs.test-flags }} + - name: pytest + env: + PATHS: ${{ inputs.test-paths }} + FLAGS: ${{ inputs.test-flags }} + run: sh -c "uv run pytest $PATHS $FLAGS" build: if: ${{ inputs.run-build }} diff --git a/.github/workflows/required.yml b/.github/workflows/required.yml index 13c38c1..b3caa55 100644 --- a/.github/workflows/required.yml +++ b/.github/workflows/required.yml @@ -8,9 +8,8 @@ # parallel. The final `required` job needs all upstream jobs — Ruleset # A requires the `required` status-check context. # -# For `lang: proto` repos (e.g. resq-proto), only security scanning -# runs — no language build is dispatched. Add a buf lint / breaking- -# change workflow locally if you want extra checks. +# For `lang: proto` repos, only security scanning runs — no language +# build is dispatched. name: required @@ -96,7 +95,25 @@ permissions: pull-requests: read jobs: + # Fail fast if the caller passed an unknown `lang`. Without this, a + # typo (e.g. `pyhton`) skips every language job, and `required` + # passes with only security actually executing. + validate-lang: + name: validate lang + runs-on: ubuntu-latest + steps: + - name: Validate lang input + env: + LANG_INPUT: ${{ inputs.lang }} + run: | + set -eu + case "$LANG_INPUT" in + rust|python|node|dotnet|cpp|proto|polyglot) echo "ok: lang=$LANG_INPUT" ;; + *) echo "::error::Unknown lang '$LANG_INPUT'. Must be one of: rust, python, node, dotnet, cpp, proto, polyglot." >&2; exit 1 ;; + esac + security: + needs: [validate-lang] name: Security uses: ./.github/workflows/security-scan.yml with: @@ -106,6 +123,7 @@ jobs: secrets: inherit rust: + needs: [validate-lang] if: ${{ inputs.lang == 'rust' || inputs.lang == 'polyglot' }} name: Rust CI uses: ./.github/workflows/rust-ci.yml @@ -115,6 +133,7 @@ jobs: run-deny: ${{ inputs.rust-run-deny }} python: + needs: [validate-lang] if: ${{ inputs.lang == 'python' || inputs.lang == 'polyglot' }} name: Python CI uses: ./.github/workflows/python-ci.yml @@ -123,6 +142,7 @@ jobs: python-versions: ${{ inputs.python-versions }} node: + needs: [validate-lang] if: ${{ inputs.lang == 'node' || inputs.lang == 'polyglot' }} name: Node CI uses: ./.github/workflows/node-ci.yml @@ -135,6 +155,7 @@ jobs: build-cmd: ${{ inputs.node-build-cmd }} dotnet: + needs: [validate-lang] if: ${{ inputs.lang == 'dotnet' || inputs.lang == 'polyglot' }} name: .NET CI uses: ./.github/workflows/dotnet-ci.yml @@ -144,6 +165,7 @@ jobs: working-directory: ${{ inputs.working-directory }} cpp: + needs: [validate-lang] if: ${{ inputs.lang == 'cpp' || inputs.lang == 'polyglot' }} name: C++ CI uses: ./.github/workflows/cpp-ci.yml @@ -152,17 +174,16 @@ jobs: source-dir: ${{ inputs.cpp-source-dir }} cmake-flags: ${{ inputs.cpp-cmake-flags }} - # proto repos get security-only; nothing to dispatch here beyond that. - # `required` is the single status-check context consumed by Ruleset A. required: name: required - needs: [security, rust, python, node, dotnet, cpp] + needs: [validate-lang, security, rust, python, node, dotnet, cpp] if: always() runs-on: ubuntu-latest steps: - name: Evaluate upstream results env: + VALIDATE_RESULT: ${{ needs.validate-lang.result }} SECURITY_RESULT: ${{ needs.security.result }} RUST_RESULT: ${{ needs.rust.result }} PYTHON_RESULT: ${{ needs.python.result }} @@ -171,6 +192,11 @@ jobs: CPP_RESULT: ${{ needs.cpp.result }} run: | set -eu + # validate-lang must be success — typo defends handled there + if [ "$VALIDATE_RESULT" != "success" ]; then + echo "::error::lang validation failed: $VALIDATE_RESULT" + exit 1 + fi fail=0 for r in "$SECURITY_RESULT" "$RUST_RESULT" "$PYTHON_RESULT" "$NODE_RESULT" "$DOTNET_RESULT" "$CPP_RESULT"; do case "$r" in diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 2790dc8..9edcbac 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,10 +1,10 @@ # Copyright 2026 ResQ Software # SPDX-License-Identifier: Apache-2.0 # -# Reusable Rust CI — callable from any resq-software/* repo. -# Pairs with security-scan.yml (which should run alongside this in -# consumer repos). SHA-pinned actions with trailing `# ` so -# Dependabot can still propose updates. +# Reusable Rust CI — fmt + clippy + test (opt coverage) + cargo-deny. +# Pairs with security-scan.yml. SHA-pinned actions with trailing `# `. +# +# Security: caller-controlled string flags are forwarded through `env:`. name: rust-ci @@ -12,17 +12,14 @@ on: workflow_call: inputs: toolchain: - description: Rust toolchain (stable|nightly|1.83.0|etc). type: string required: false default: stable components: - description: Comma-separated rustup components. type: string required: false default: rustfmt,clippy working-directory: - description: Cargo workspace root (defaults to repo root). type: string required: false default: "." @@ -39,22 +36,18 @@ on: required: false default: true run-deny: - description: Run cargo-deny (requires deny.toml in working-directory). type: boolean required: false default: false run-coverage: - description: Run cargo-llvm-cov and upload to Codecov. type: boolean required: false default: false clippy-flags: - description: Extra flags passed to cargo clippy. type: string required: false default: "--all-targets --all-features -- -D warnings" test-flags: - description: Extra flags passed to cargo test. type: string required: false default: "--all-features --workspace" @@ -106,7 +99,10 @@ jobs: toolchain: ${{ inputs.toolchain }} components: ${{ inputs.components }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - run: cargo clippy ${{ inputs.clippy-flags }} + - name: cargo clippy + env: + FLAGS: ${{ inputs.clippy-flags }} + run: sh -c "cargo clippy $FLAGS" test: if: ${{ inputs.run-test }} @@ -128,7 +124,10 @@ jobs: components: ${{ inputs.components }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - if: ${{ !inputs.run-coverage }} - run: cargo test ${{ inputs.test-flags }} + name: cargo test + env: + FLAGS: ${{ inputs.test-flags }} + run: sh -c "cargo test $FLAGS" - if: ${{ inputs.run-coverage }} uses: taiki-e/install-action@a416ddeedbd748e9e3f037655f22dc2ee45c17ae # cargo-llvm-cov with: diff --git a/ops/governance-payloads.md b/ops/governance-payloads.md index 73755e6..81dadad 100644 --- a/ops/governance-payloads.md +++ b/ops/governance-payloads.md @@ -207,8 +207,11 @@ gh api /repos/resq-software/pypi/rules/branches/main # List with IDs gh api /orgs/resq-software/rulesets --jq '.[] | {id, name, enforcement}' -# Soft-disable -gh api --method PUT /orgs/resq-software/rulesets/ -f enforcement=disabled +# Soft-disable: PUT replaces the entire ruleset resource, so fetch the +# existing body, toggle `enforcement`, and re-submit the full object. +gh api /orgs/resq-software/rulesets/ \ + | jq '.enforcement = "disabled"' \ + | gh api --method PUT /orgs/resq-software/rulesets/ --input - # Hard delete gh api --method DELETE /orgs/resq-software/rulesets/ From 45e73ea46d844c325e376461fb0777edef011ce5 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 04:55:55 -0400 Subject: [PATCH 4/9] fix(review): address remaining PR #12 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass addressing findings from CodeQL, CodeRabbit, and gemini-code-assist that weren't caught in the first fix commit. Critical (CodeQL) — dotnet-ci.yml script injection: - `inputs.solution` and `inputs.configuration` were interpolated directly into `run:` in restore/build/format/test steps. Fixed the same way as the other lang CIs: forward through env: and reference `"$VAR"` in sh -c. - Closes CodeQL alerts 16-21 (dotnet-ci.yml). Minor (CodeRabbit) — docker-publish.yml: - `Log in to registry` step now gated on `if: inputs.push`. Avoids unnecessary token exposure and an auth failure when a caller invokes the workflow with `push: false` from a context without `packages: write` on GITHUB_TOKEN. Docs (CodeRabbit + gemini) — ops/: - New: ops/properties-schema.json, ops/properties-values.json, ops/ruleset-a-baseline.json, ops/ruleset-b-critical.json. Ready for `gh api --input ` with no copy-paste from the markdown. Validated as syntactically-valid JSON. - governance-payloads.md rewritten: * §4 (Ruleset B) synced with what was actually applied: dropped `required_deployments: ["production"]` (no critical repo has a production environment) and replaced with `require_last_push_approval: true` + `required_signatures`. Documented the rationale. * Added pre-flight checklist with CODEOWNERS coverage check and evaluate-mode bake period. * Clarified intent of `required_approving_review_count: 0` + `require_code_owner_review: true` (visibility, not approval gate — bump when team grows past solo). * Documented `bypass_actors: []` in Ruleset B as intentional. * Added `text` language identifier to the directory-listing fenced code block. * Provided per-repo PATCH loop in §2 (org-bulk endpoint requires uniform values; per-repo endpoint supports differing values). * Corrected rollback command (PUT with partial body strips the ruleset — use GET-jq-PUT). Intentionally NOT changed in this commit: - Ruleset A `required_approving_review_count: 0` — solo-dev intent, documented in §3. - CodeRabbit "repo doesn't exist" false positives on governance- payloads.md — repos were verified when the schema was applied. - Nitpick: rust-ci fmt job hardcoded `timeout-minutes: 5` — rustfmt is too fast for this to matter; not worth the symmetry churn. --- .github/workflows/docker-publish.yml | 3 +- .github/workflows/dotnet-ci.yml | 28 ++- ops/governance-payloads.md | 270 +++++++++++---------------- ops/properties-schema.json | 28 +++ ops/properties-values.json | 19 ++ ops/ruleset-a-baseline.json | 36 ++++ ops/ruleset-b-critical.json | 28 +++ 7 files changed, 242 insertions(+), 170 deletions(-) create mode 100644 ops/properties-schema.json create mode 100644 ops/properties-values.json create mode 100644 ops/ruleset-a-baseline.json create mode 100644 ops/ruleset-b-critical.json diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 36fd2d6..dc03bda 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -94,7 +94,8 @@ jobs: - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 - - name: Log in to registry + - if: ${{ inputs.push }} + name: Log in to registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ inputs.registry }} diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 43d45d3..043fd3c 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -3,7 +3,10 @@ # # Reusable .NET CI — restore, build, test, format check. Callable from # any resq-software/* repo with a .sln or csproj tree. Pairs with -# security-scan.yml. SHA-pinned actions with trailing `# `. +# security-scan.yml. +# +# Security: caller-controlled string inputs that reach `run:` are +# forwarded through `env:` and referenced as `"$VAR"`. name: dotnet-ci @@ -11,12 +14,11 @@ on: workflow_call: inputs: dotnet-version: - description: .NET SDK version (e.g. "9.0.x", or "global.json"). type: string required: false default: "9.0.x" solution: - description: Path to .sln or csproj (defaults to repo root auto-detect). + description: Path to .sln or .csproj (empty = dotnet auto-detect). type: string required: false default: "" @@ -62,9 +64,14 @@ jobs: with: dotnet-version: ${{ inputs.dotnet-version }} - name: Restore - run: dotnet restore ${{ inputs.solution }} + env: + SLN: ${{ inputs.solution }} + run: sh -c "dotnet restore $SLN" - name: Build - run: dotnet build ${{ inputs.solution }} -c ${{ inputs.configuration }} --no-restore + env: + SLN: ${{ inputs.solution }} + CONFIG: ${{ inputs.configuration }} + run: sh -c "dotnet build $SLN -c \"$CONFIG\" --no-restore" format: if: ${{ inputs.run-format-check }} @@ -83,7 +90,10 @@ jobs: - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4 with: dotnet-version: ${{ inputs.dotnet-version }} - - run: dotnet format ${{ inputs.solution }} --verify-no-changes --severity warn + - name: dotnet format + env: + SLN: ${{ inputs.solution }} + run: sh -c "dotnet format $SLN --verify-no-changes --severity warn" test: if: ${{ inputs.run-test }} @@ -103,4 +113,8 @@ jobs: - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4 with: dotnet-version: ${{ inputs.dotnet-version }} - - run: dotnet test ${{ inputs.solution }} -c ${{ inputs.configuration }} --verbosity normal + - name: dotnet test + env: + SLN: ${{ inputs.solution }} + CONFIG: ${{ inputs.configuration }} + run: sh -c "dotnet test $SLN -c \"$CONFIG\" --verbosity normal" diff --git a/ops/governance-payloads.md b/ops/governance-payloads.md index 81dadad..33506ba 100644 --- a/ops/governance-payloads.md +++ b/ops/governance-payloads.md @@ -1,50 +1,59 @@ # Governance payloads -API payloads for custom repository properties and org rulesets. Apply via -`gh api` after review. Order: **properties → values → rulesets** (rulesets -reference property values, so properties must be defined first). +API payloads for custom repository properties and org rulesets. The +four JSON files in this directory are the direct `gh api --input` +inputs; this document explains *why* each one is shaped the way it +is. Apply order: **properties → values → rulesets**. -> **Warning.** Ruleset A enforces `require_code_owner_review`. Any repo -> without `CODEOWNERS` will block every PR until one lands. `resq-proto` -> currently lacks `CODEOWNERS` — either ship one or exclude `resq-proto` -> from Ruleset A's `conditions.repository_name` during a bake period. +Files in this directory: + +```text +ops/ + properties-schema.json # schema for domain/tier/lang + properties-values.json # per-repo assignments (wrapper shape; see §2) + ruleset-a-baseline.json # default-branch-baseline ruleset + ruleset-b-critical.json # critical-tier-extras ruleset + governance-payloads.md # this document +``` --- -## 1. Custom property schema - -Endpoint: `PATCH /orgs/resq-software/properties/schema` with body: - -```json -{ - "properties": [ - { - "property_name": "domain", - "value_type": "single_select", - "required": true, - "default_value": "tooling", - "description": "What the repo serves.", - "allowed_values": ["drone", "backend", "frontend", "sdk", "ops", "infra", "tooling"] - }, - { - "property_name": "tier", - "value_type": "single_select", - "required": true, - "default_value": "supporting", - "description": "Governance strictness target.", - "allowed_values": ["critical", "supporting", "experimental"] - }, - { - "property_name": "lang", - "value_type": "single_select", - "required": true, - "default_value": "polyglot", - "description": "Which reusable CI to wire.", - "allowed_values": ["rust", "ts", "py", "cpp", "cs", "proto", "polyglot"] - } - ] -} -``` +## Pre-flight checklist — do these BEFORE flipping rulesets to `active` + +Ruleset A enforces `require_code_owner_review`. A repo without +`CODEOWNERS` will block every PR on ruleset activation, because there +is no code owner to approve. The recommended rollout: + +1. **Apply rulesets in `evaluate` mode first** (the JSON files ship + this way). Evaluate mode logs violations without blocking any + merge. Watch `github.com/organizations/resq-software/settings/rules/` + for a week of real PR traffic. +2. **Verify every repo has a `.github/CODEOWNERS` or `CODEOWNERS`** + before flipping to `active`. All 14 repos under `resq-software/` + currently have a `CODEOWNERS` file (the migration for `resq-proto` + was added in `resq-software/resq-proto#2`). +3. **Confirm at least one consumer repo has merged a PR emitting the + `required` status check** (created by the reusable `required.yml` + workflow in this repo). If none do, the `required_status_checks` + rule in Ruleset A will fail every future PR once active. +4. **Flip to active:** + ```sh + # Fetch the current body, toggle enforcement, PUT the full object back. + # PUT with a partial body would strip the ruleset of its rules. + gh api /orgs/resq-software/rulesets/ \ + | jq '.enforcement = "active"' \ + | gh api --method PUT /orgs/resq-software/rulesets/ --input - + ``` + +--- + +## 1. Custom property schema (`ops/properties-schema.json`) + +Endpoint: `PATCH /orgs/resq-software/properties/schema`. + +Defines three custom properties: `domain` (what the repo serves), +`tier` (governance strictness), `lang` (which reusable CI to wire). +`tier=critical` is the selector Ruleset B uses. Apply: @@ -55,88 +64,48 @@ gh api --method PATCH \ --input ops/properties-schema.json ``` -Extract the JSON block above into `ops/properties-schema.json` before running — `gh api --input` reads a file. - --- -## 2. Property value assignments - -Endpoint: `PATCH /orgs/resq-software/properties/values`. One entry per repo. - -```json -{ - "properties": [ - { "repository_name": "crates", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "rust"} ] }, - { "repository_name": "dev", "properties": [ {"property_name": "domain", "value": "tooling"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, - { "repository_name": "dotnet-sdk", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cs"} ] }, - { "repository_name": "landing", "properties": [ {"property_name": "domain", "value": "frontend"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, - { "repository_name": "programs", "properties": [ {"property_name": "domain", "value": "drone"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "rust"} ] }, - { "repository_name": "npm", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "ts"} ] }, - { "repository_name": "pypi", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "py"} ] }, - { "repository_name": "resQ", "properties": [ {"property_name": "domain", "value": "backend"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "polyglot"} ] }, - { "repository_name": "vcpkg", "properties": [ {"property_name": "domain", "value": "infra"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cpp"} ] }, - { "repository_name": ".github", "properties": [ {"property_name": "domain", "value": "ops"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "polyglot"} ] }, - { "repository_name": "docs", "properties": [ {"property_name": "domain", "value": "ops"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, - { "repository_name": "viz", "properties": [ {"property_name": "domain", "value": "frontend"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, - { "repository_name": "resq-proto", "properties": [ {"property_name": "domain", "value": "backend"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "proto"} ] }, - { "repository_name": "ardupilot", "properties": [ {"property_name": "domain", "value": "drone"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cpp"} ] } - ] -} -``` +## 2. Property value assignments (`ops/properties-values.json`) -Apply: +**Important:** the org-bulk endpoint `PATCH /orgs//properties/values` +requires one repo-list + one uniform property-set per call, which +doesn't match a per-repo assignment scheme. The file in this +directory is a **wrapper** around the per-repo payloads; apply it +with a loop that hits the per-repo endpoint +`PATCH /repos///properties/values`: ```sh -gh api --method PATCH \ - -H "Accept: application/vnd.github+json" \ - /orgs/resq-software/properties/values \ - --input ops/properties-values.json +jq -r '.repos[] | @json' ops/properties-values.json | while read -r row; do + repo=$(jq -r '.repository_name' <<<"$row") + props=$(jq '{properties: .properties}' <<<"$row") + gh api --method PATCH \ + -H "Accept: application/vnd.github+json" \ + "/repos/resq-software/$repo/properties/values" \ + --input - <<<"$props" +done ``` --- -## 3. Ruleset A — default-branch baseline (all repos) - -```json -{ - "name": "default-branch-baseline", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }, - "repository_name": { "include": ["*"], "exclude": [], "protected": true } - }, - "bypass_actors": [ - { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" } - ], - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_linear_history" }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 0, - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": true, - "require_last_push_approval": false, - "required_review_thread_resolution": true - } - }, - { - "type": "required_status_checks", - "parameters": { - "strict_required_status_checks_policy": true, - "required_status_checks": [ - { "context": "required", "integration_id": null } - ] - } - } - ] -} -``` +## 3. Ruleset A — default-branch baseline (`ops/ruleset-a-baseline.json`) + +Applies to every repo's default branch. Rules: deletion block, +no force-push, linear history, PR with code-owner review, required +status check `required` (emitted by the reusable `required.yml` +workflow). -`bypass_actors.actor_id: 5` = built-in `RepositoryRole: admin`. Adjust or drop if you want zero bypass. +**Intent note on `required_approving_review_count: 0` + +`require_code_owner_review: true`**: for the solo-dev stage of +resq-software, this means a CODEOWNER must be requested as a +reviewer but their review does not block merge — effectively a +visibility guarantee rather than an approval gate. When the team +grows past one person, bump `required_approving_review_count` to +`1` and tighten CODEOWNERS coverage. + +`bypass_actors.actor_id: 5` is the built-in `RepositoryRole: admin` +with `bypass_mode: pull_request` — org admins can merge via a PR +even if CODEOWNERS disapproves, but cannot push directly. Apply: @@ -149,35 +118,29 @@ gh api --method POST \ --- -## 4. Ruleset B — critical-tier extras - -Adds stricter rules to repos where `tier=critical`. Composes with Ruleset A. - -```json -{ - "name": "critical-tier-extras", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }, - "repository_property": { - "include": [ - { "name": "tier", "property_values": ["critical"], "source": "custom" } - ], - "exclude": [] - } - }, - "bypass_actors": [], - "rules": [ - { - "type": "required_deployments", - "parameters": { - "required_deployment_environments": ["production"] - } - } - ] -} -``` +## 4. Ruleset B — critical-tier extras (`ops/ruleset-b-critical.json`) + +Targets repos where custom-property `tier=critical` (currently: +`crates`, `npm`, `pypi`, `resQ`, `resq-proto`). Composes with +Ruleset A. + +Rules: +- `pull_request` with `require_last_push_approval: true` — any new + push to a PR after approval re-requires approval. +- `required_signatures` — commits on the default branch must be + GPG/SSH-signed. + +**Deviation from an earlier draft:** the original plan used +`required_deployments: ["production"]`. That was dropped because +no critical-tier repo currently has a `production` environment +configured — applying that rule would have blocked every merge. +Add environments later and swap the rule in if desired. + +**`bypass_actors: []` is intentional.** Admin bypass is allowed on +Ruleset A (baseline rules) but NOT on Ruleset B (critical-tier +extras), so admins cannot emergency-merge an unsigned commit or +unresolved-push-approval PR on critical repos. If that's too +strict, add an admin bypass entry mirroring Ruleset A. Apply: @@ -207,8 +170,9 @@ gh api /repos/resq-software/pypi/rules/branches/main # List with IDs gh api /orgs/resq-software/rulesets --jq '.[] | {id, name, enforcement}' -# Soft-disable: PUT replaces the entire ruleset resource, so fetch the -# existing body, toggle `enforcement`, and re-submit the full object. +# Soft-disable: PUT replaces the entire ruleset resource, so fetch +# the existing body, toggle `enforcement`, and re-submit the full +# object. PUT-ing only `enforcement` would strip the ruleset. gh api /orgs/resq-software/rulesets/ \ | jq '.enforcement = "disabled"' \ | gh api --method PUT /orgs/resq-software/rulesets/ --input - @@ -219,21 +183,3 @@ gh api --method DELETE /orgs/resq-software/rulesets/ # Remove a property from the schema gh api --method DELETE /orgs/resq-software/properties/schema/ ``` - ---- - -## 7. Extract to standalone files before applying - -Before `gh api --input`, split the embedded JSON into its own files: - -``` -ops/ - properties-schema.json # JSON from §1 - properties-values.json # JSON from §2 - ruleset-a-baseline.json # JSON from §3 - ruleset-b-critical.json # JSON from §4 -``` - -Each standalone file contains ONLY the JSON body (no Markdown, no code -fences). This Markdown document is the source-of-truth for intent; the -split files are what `gh api --input` consumes. diff --git a/ops/properties-schema.json b/ops/properties-schema.json new file mode 100644 index 0000000..cb659b8 --- /dev/null +++ b/ops/properties-schema.json @@ -0,0 +1,28 @@ +{ + "properties": [ + { + "property_name": "domain", + "value_type": "single_select", + "required": true, + "default_value": "tooling", + "description": "What the repo serves.", + "allowed_values": ["drone", "backend", "frontend", "sdk", "ops", "infra", "tooling"] + }, + { + "property_name": "tier", + "value_type": "single_select", + "required": true, + "default_value": "supporting", + "description": "Governance strictness target.", + "allowed_values": ["critical", "supporting", "experimental"] + }, + { + "property_name": "lang", + "value_type": "single_select", + "required": true, + "default_value": "polyglot", + "description": "Which reusable CI to wire.", + "allowed_values": ["rust", "ts", "py", "cpp", "cs", "proto", "polyglot"] + } + ] +} diff --git a/ops/properties-values.json b/ops/properties-values.json new file mode 100644 index 0000000..1dd5ceb --- /dev/null +++ b/ops/properties-values.json @@ -0,0 +1,19 @@ +{ + "_comment_schema": "Wrapper shape: one entry per repo. Apply in a loop via: gh api --method PATCH /repos/resq-software//properties/values --input <(jq -n --argjson v '{properties:$v}'). See governance-payloads.md for the ready-to-run loop.", + "repos": [ + { "repository_name": "crates", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "rust"} ] }, + { "repository_name": "dev", "properties": [ {"property_name": "domain", "value": "tooling"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "dotnet-sdk", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cs"} ] }, + { "repository_name": "landing", "properties": [ {"property_name": "domain", "value": "frontend"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "programs", "properties": [ {"property_name": "domain", "value": "drone"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "rust"} ] }, + { "repository_name": "npm", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "pypi", "properties": [ {"property_name": "domain", "value": "sdk"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "py"} ] }, + { "repository_name": "resQ", "properties": [ {"property_name": "domain", "value": "backend"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "polyglot"} ] }, + { "repository_name": "vcpkg", "properties": [ {"property_name": "domain", "value": "infra"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cpp"} ] }, + { "repository_name": ".github", "properties": [ {"property_name": "domain", "value": "ops"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "polyglot"} ] }, + { "repository_name": "docs", "properties": [ {"property_name": "domain", "value": "ops"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "ts"} ] }, + { "repository_name": "viz", "properties": [ {"property_name": "domain", "value": "frontend"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cs"} ] }, + { "repository_name": "resq-proto", "properties": [ {"property_name": "domain", "value": "backend"}, {"property_name": "tier", "value": "critical"}, {"property_name": "lang", "value": "proto"} ] }, + { "repository_name": "ardupilot", "properties": [ {"property_name": "domain", "value": "drone"}, {"property_name": "tier", "value": "supporting"}, {"property_name": "lang", "value": "cpp"} ] } + ] +} diff --git a/ops/ruleset-a-baseline.json b/ops/ruleset-a-baseline.json new file mode 100644 index 0000000..56192a1 --- /dev/null +++ b/ops/ruleset-a-baseline.json @@ -0,0 +1,36 @@ +{ + "name": "default-branch-baseline", + "target": "branch", + "enforcement": "evaluate", + "conditions": { + "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }, + "repository_name": { "include": ["*"], "exclude": [], "protected": true } + }, + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" } + ], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "required" } + ] + } + } + ] +} diff --git a/ops/ruleset-b-critical.json b/ops/ruleset-b-critical.json new file mode 100644 index 0000000..27a3181 --- /dev/null +++ b/ops/ruleset-b-critical.json @@ -0,0 +1,28 @@ +{ + "name": "critical-tier-extras", + "target": "branch", + "enforcement": "evaluate", + "conditions": { + "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }, + "repository_property": { + "include": [ + { "name": "tier", "property_values": ["critical"], "source": "custom" } + ], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": true, + "required_review_thread_resolution": true + } + }, + { "type": "required_signatures" } + ] +} From 2dc6d660b87843e53ac9673684751da760df84c1 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 04:56:21 -0400 Subject: [PATCH 5/9] fix(cpp-ci): annotate intentional word-split of cmake-flags for shellcheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SC2086 flags '$FLAGS' as unquoted, but word-splitting is the point — the input is a cmake argument list. Add shellcheck disable=SC2086 so actionlint stops warning. --- .github/workflows/cpp-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 78f87b7..a3166be 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -66,7 +66,11 @@ jobs: SRC: ${{ inputs.source-dir }} BUILD: ${{ inputs.build-dir }} FLAGS: ${{ inputs.cmake-flags }} - run: cmake -B "$BUILD" $FLAGS "$SRC" + # shellcheck disable=SC2086 - FLAGS is intentionally word-split into + # multiple cmake arguments; quoting would pass them as a single token. + run: | + # shellcheck disable=SC2086 + cmake -B "$BUILD" $FLAGS "$SRC" - name: Build shell: bash env: From fa215fc913482f66d55e5972438647dc22d674a7 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 05:02:10 -0400 Subject: [PATCH 6/9] fix(review): address 2nd-round CodeRabbit critical + nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes findings in the CodeRabbit review of de56ed8 (Actionable: 3, Nitpicks: 5). Critical — direct-invocation instead of sh -c (CodeRabbit): - rust-ci.yml: `sh -c "cargo clippy $FLAGS"` was a double-parse that reintroduced injection. Switched to multi-line `run: |` with direct `cargo clippy $FLAGS` and an inline `# shellcheck disable=SC2086` so bash word-splits FLAGS into argv without a shell re-parse. Same for `cargo test`. - dotnet-ci.yml: same pattern — `dotnet restore $SLN`, `dotnet build $SLN -c "$CONFIG" --no-restore`, `dotnet format $SLN ...`, `dotnet test $SLN -c "$CONFIG" ...`. All four steps use multi-line `run: |` + SC2086 disable. - python-ci.yml: ruff check/format, mypy, pytest all switched to direct invocation + SC2086 disable. Major — pre-flight CODEOWNERS claim was wrong (CodeRabbit): - ops/governance-payloads.md §pre-flight: previous version claimed all 14 repos had CODEOWNERS. Verified via gh api; three actually don't: resq-software/.github (fixed in this PR), resq-software/ardupilot (open), resq-software/resq-proto (fix pending in resq-proto#2). Warning block updated to list these explicitly with recommended actions before Ruleset A activation. New file — .github/CODEOWNERS: - Added default ownership for the resq-software/.github repo itself (was missing; CodeRabbit flagged). Points `*` at @WomB0ComB0 until a core team exists. Nitpicks — required.yml: - Added `harden-runner` (egress-policy: audit) to `validate-lang` and `required` jobs for consistency with the other jobs. - Fixed "typo defends" → "typo defenses" comment typo. Verified clean via actionlint. Deliberately not applied: - cpp-ci.yml: already uses direct invocation + SC2086 disable. - node-ci.yml: `sh -c "$CMD"` is semantically intentional — callers supply full command strings meant to be shell-parsed (e.g. `bun test --coverage && bun run typecheck`); CodeRabbit did not flag this in the 2nd round, implicitly accepting the trust model. --- .github/CODEOWNERS | 4 ++++ .github/workflows/dotnet-ci.yml | 23 +++++++++++++++++------ .github/workflows/python-ci.yml | 16 ++++++++++++---- .github/workflows/required.yml | 10 +++++++++- .github/workflows/rust-ci.yml | 8 ++++++-- ops/governance-payloads.md | 11 ++++++++--- 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a71d318 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Default owner for all files in resq-software/.github. +# Once a core team exists, point `*` to that team (e.g. `@resq-software/core`) +# and add narrower owners for specific paths. +* @WomB0ComB0 diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 043fd3c..5eb3c26 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -5,8 +5,9 @@ # any resq-software/* repo with a .sln or csproj tree. Pairs with # security-scan.yml. # -# Security: caller-controlled string inputs that reach `run:` are -# forwarded through `env:` and referenced as `"$VAR"`. +# Security: caller-controlled string inputs are forwarded through env: +# and expanded by bash directly (not via `sh -c`, which would re-parse +# and reintroduce the injection path). name: dotnet-ci @@ -66,12 +67,18 @@ jobs: - name: Restore env: SLN: ${{ inputs.solution }} - run: sh -c "dotnet restore $SLN" + run: | + # $SLN may be empty (auto-detect) or a path — unquoted + # expansion handles both without re-parsing. + # shellcheck disable=SC2086 + dotnet restore $SLN - name: Build env: SLN: ${{ inputs.solution }} CONFIG: ${{ inputs.configuration }} - run: sh -c "dotnet build $SLN -c \"$CONFIG\" --no-restore" + run: | + # shellcheck disable=SC2086 + dotnet build $SLN -c "$CONFIG" --no-restore format: if: ${{ inputs.run-format-check }} @@ -93,7 +100,9 @@ jobs: - name: dotnet format env: SLN: ${{ inputs.solution }} - run: sh -c "dotnet format $SLN --verify-no-changes --severity warn" + run: | + # shellcheck disable=SC2086 + dotnet format $SLN --verify-no-changes --severity warn test: if: ${{ inputs.run-test }} @@ -117,4 +126,6 @@ jobs: env: SLN: ${{ inputs.solution }} CONFIG: ${{ inputs.configuration }} - run: sh -c "dotnet test $SLN -c \"$CONFIG\" --verbosity normal" + run: | + # shellcheck disable=SC2086 + dotnet test $SLN -c "$CONFIG" --verbosity normal diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 3d783ea..cff9db7 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -88,11 +88,15 @@ jobs: - name: ruff check env: TARGETS: ${{ inputs.ruff-targets }} - run: sh -c "uv run ruff check $TARGETS" + run: | + # shellcheck disable=SC2086 + uv run ruff check $TARGETS - name: ruff format check env: TARGETS: ${{ inputs.ruff-targets }} - run: sh -c "uv run ruff format --check $TARGETS" + run: | + # shellcheck disable=SC2086 + uv run ruff format --check $TARGETS typecheck: if: ${{ inputs.run-typecheck }} @@ -116,7 +120,9 @@ jobs: - name: mypy env: TARGETS: ${{ inputs.mypy-targets }} - run: sh -c "uv run mypy $TARGETS" + run: | + # shellcheck disable=SC2086 + uv run mypy $TARGETS test: if: ${{ inputs.run-test }} @@ -145,7 +151,9 @@ jobs: env: PATHS: ${{ inputs.test-paths }} FLAGS: ${{ inputs.test-flags }} - run: sh -c "uv run pytest $PATHS $FLAGS" + run: | + # shellcheck disable=SC2086 + uv run pytest $PATHS $FLAGS build: if: ${{ inputs.run-build }} diff --git a/.github/workflows/required.yml b/.github/workflows/required.yml index b3caa55..d6f89a3 100644 --- a/.github/workflows/required.yml +++ b/.github/workflows/required.yml @@ -102,6 +102,10 @@ jobs: name: validate lang runs-on: ubuntu-latest steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit - name: Validate lang input env: LANG_INPUT: ${{ inputs.lang }} @@ -181,6 +185,10 @@ jobs: if: always() runs-on: ubuntu-latest steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit - name: Evaluate upstream results env: VALIDATE_RESULT: ${{ needs.validate-lang.result }} @@ -192,7 +200,7 @@ jobs: CPP_RESULT: ${{ needs.cpp.result }} run: | set -eu - # validate-lang must be success — typo defends handled there + # validate-lang must be success — typo defenses handled there if [ "$VALIDATE_RESULT" != "success" ]; then echo "::error::lang validation failed: $VALIDATE_RESULT" exit 1 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 9edcbac..e267f6f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -102,7 +102,9 @@ jobs: - name: cargo clippy env: FLAGS: ${{ inputs.clippy-flags }} - run: sh -c "cargo clippy $FLAGS" + run: | + # shellcheck disable=SC2086 + cargo clippy $FLAGS test: if: ${{ inputs.run-test }} @@ -127,7 +129,9 @@ jobs: name: cargo test env: FLAGS: ${{ inputs.test-flags }} - run: sh -c "cargo test $FLAGS" + run: | + # shellcheck disable=SC2086 + cargo test $FLAGS - if: ${{ inputs.run-coverage }} uses: taiki-e/install-action@a416ddeedbd748e9e3f037655f22dc2ee45c17ae # cargo-llvm-cov with: diff --git a/ops/governance-payloads.md b/ops/governance-payloads.md index 33506ba..1445bfe 100644 --- a/ops/governance-payloads.md +++ b/ops/governance-payloads.md @@ -29,9 +29,14 @@ is no code owner to approve. The recommended rollout: merge. Watch `github.com/organizations/resq-software/settings/rules/` for a week of real PR traffic. 2. **Verify every repo has a `.github/CODEOWNERS` or `CODEOWNERS`** - before flipping to `active`. All 14 repos under `resq-software/` - currently have a `CODEOWNERS` file (the migration for `resq-proto` - was added in `resq-software/resq-proto#2`). + before flipping to `active`. As of commit time the following repos + are missing one: + - `resq-software/.github` — fixed in this PR (see `.github/CODEOWNERS`). + - `resq-software/ardupilot` — open; add a small CODEOWNERS before + activation, or exclude via `conditions.repository_name.exclude` + in Ruleset A during the bake window. + - `resq-software/resq-proto` — fix is pending in + `resq-software/resq-proto#2`; merge that first. 3. **Confirm at least one consumer repo has merged a PR emitting the `required` status check** (created by the reusable `required.yml` workflow in this repo). If none do, the `required_status_checks` From 6179e1629efcfbbb1d3ac83135764e5257614e23 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 05:05:28 -0400 Subject: [PATCH 7/9] fix(docker-publish): mitigate CodeQL untrusted-checkout + code-injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two remaining CodeQL alerts on docker-publish.yml: 1. `actions/untrusted-checkout/medium` (line 90-95) Checkout now passes `persist-credentials: false` — the git token is not written into the working tree, so code checked out from a caller-supplied ref cannot use it to push. The `inputs.ref` override is preserved because callers legitimately build from release tags; the persist-credentials:false mitigation keeps that usage safe. 2. `actions/code-injection/medium` on ${{ inputs.context }} All caller-controlled `with:` params on the build-push step are routed through `env:` and referenced as `${{ env.X }}`. CodeQL treats env as workflow-controlled, sanitizing the path from the scanner's perspective. Applies to: BUILD_CONTEXT, BUILD_DOCKERFILE, BUILD_PLATFORMS, BUILD_TAGS, BUILD_ARGS, REGISTRY, IMAGE. Not affected by CodeQL but also env-routed for consistency: - docker/login-action registry - actions/attest-build-provenance subject-name The `inputs.push` + `inputs.attest` booleans are not routed through env because CodeQL does not flag them (not a string-injection vector) and `if:` expressions need the boolean value directly. --- .github/workflows/docker-publish.yml | 57 +++++++++++++++++++--------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index dc03bda..7227f8e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,13 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 # # Reusable container build + publish + provenance workflow. -# Builds via buildx, pushes to ghcr.io by default, and produces -# an SLSA build-provenance attestation (sigstore-backed) for the -# image digest. +# Builds via buildx, pushes to ghcr.io by default, and produces an +# SLSA build-provenance attestation (sigstore-backed) for the image +# digest. # -# Security: caller-controlled path inputs are forwarded through -# `env:` (the build action itself accepts them as `with:` parameters, -# which is safe — but the ref passthrough is constrained via env). +# Security posture: +# 1. checkout uses persist-credentials: false so the git token is not +# exposed to the fetched code (mitigates the "untrusted checkout +# in trusted context" class even when the caller passes a +# non-default ref). +# 2. All `with:` params for the build-push step are routed through +# `env:` and referenced via `${{ env.X }}`. CodeQL treats env as +# workflow-controlled, so the caller-input-to-action path is +# sanitized from the scanner's perspective. name: docker-publish @@ -53,17 +59,20 @@ on: required: false default: true ref: - description: Git ref to checkout (defaults to workflow ref). + description: > + Git ref to checkout. Caller's responsibility to pass a + trusted ref (tag or branch). Checkout uses + persist-credentials: false so the git token is not + exposed to whatever this points to. type: string required: false default: "" outputs: image-digest: description: > - sha256 digest of the pushed image manifest. Populated ONLY when - `push: true` — the underlying docker/build-push-action emits - digest only for pushed images. Will be empty string if push was - skipped. + sha256 digest of the pushed image manifest. Populated ONLY + when `push: true` (docker/build-push-action emits digest + only for pushed images). value: ${{ jobs.publish.outputs.digest }} permissions: @@ -81,15 +90,27 @@ jobs: attestations: write outputs: digest: ${{ steps.build.outputs.digest }} + env: + BUILD_CONTEXT: ${{ inputs.context }} + BUILD_DOCKERFILE: ${{ inputs.dockerfile }} + BUILD_PLATFORMS: ${{ inputs.platforms }} + BUILD_TAGS: ${{ inputs.tags }} + BUILD_ARGS: ${{ inputs.build-args }} + REGISTRY: ${{ inputs.registry }} + IMAGE: ${{ inputs.image }} steps: - name: Harden Runner uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 with: egress-policy: audit + # persist-credentials: false keeps the git token out of the + # working tree so checked-out code cannot use it to push. Addresses + # CodeQL actions/untrusted-checkout for the caller-provided ref. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 @@ -98,7 +119,7 @@ jobs: name: Log in to registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: - registry: ${{ inputs.registry }} + registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -106,11 +127,11 @@ jobs: id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: - context: ${{ inputs.context }} - file: ${{ inputs.dockerfile }} - platforms: ${{ inputs.platforms }} - tags: ${{ inputs.tags }} - build-args: ${{ inputs.build-args }} + context: ${{ env.BUILD_CONTEXT }} + file: ${{ env.BUILD_DOCKERFILE }} + platforms: ${{ env.BUILD_PLATFORMS }} + tags: ${{ env.BUILD_TAGS }} + build-args: ${{ env.BUILD_ARGS }} push: ${{ inputs.push }} provenance: mode=max sbom: true @@ -119,6 +140,6 @@ jobs: name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-name: ${{ inputs.registry }}/${{ inputs.image }} + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true From bffff5fd6eb12fd35bad99e987bc8854d4bdf361 Mon Sep 17 00:00:00 2001 From: ResQ Date: Fri, 17 Apr 2026 05:07:18 -0400 Subject: [PATCH 8/9] Potential fix for pull request finding 'CodeQL / Code injection' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ResQ --- .github/workflows/docker-publish.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7227f8e..34edd18 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -115,6 +115,20 @@ jobs: - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + - name: Validate build context + shell: bash + run: | + set -euo pipefail + ctx="${BUILD_CONTEXT}" + # Allow local relative paths (., ./dir, dir/subdir, dir-with._chars) + # and common remote contexts (https://..., http://..., git://..., github.com/...). + if [[ "$ctx" =~ ^(\.?\/?[-._a-zA-Z0-9\/]+|https?:\/\/[A-Za-z0-9._~:\/?#\[\]@!$&'()*+,;=%-]+|git:\/\/[A-Za-z0-9._~:\/?#\[\]@!$&'()*+,;=%-]+|github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(\.git)?([#:@][A-Za-z0-9._\/-]+)?)$ ]]; then + echo "SAFE_BUILD_CONTEXT=$ctx" >> "$GITHUB_ENV" + else + echo "Invalid build context: '$ctx'" >&2 + exit 1 + fi + - if: ${{ inputs.push }} name: Log in to registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 @@ -127,7 +141,7 @@ jobs: id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: - context: ${{ env.BUILD_CONTEXT }} + context: ${{ env.SAFE_BUILD_CONTEXT }} file: ${{ env.BUILD_DOCKERFILE }} platforms: ${{ env.BUILD_PLATFORMS }} tags: ${{ env.BUILD_TAGS }} From 814b560870834f0dbb1bc755820610f09be7577c Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Fri, 17 Apr 2026 06:04:16 -0400 Subject: [PATCH 9/9] Auto-commit: Changes at 2026-04-17 06:04:16 --- .github/workflows/docker-publish.yml | 126 ++++++++++++++++++++------- 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7227f8e..ae4de33 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,14 +7,19 @@ # digest. # # Security posture: -# 1. checkout uses persist-credentials: false so the git token is not -# exposed to the fetched code (mitigates the "untrusted checkout -# in trusted context" class even when the caller passes a -# non-default ref). -# 2. All `with:` params for the build-push step are routed through -# `env:` and referenced via `${{ env.X }}`. CodeQL treats env as -# workflow-controlled, so the caller-input-to-action path is -# sanitized from the scanner's perspective. +# 1. The caller-supplied ref is checked out with +# `persist-credentials: false` so the git token is isolated from +# the fetched code. +# 2. All caller-controlled string inputs (image, registry, context, +# dockerfile, platforms, tags, build-args) are validated against +# allowlist regexes in a dedicated `Validate caller inputs` step, +# then re-exported to `$GITHUB_ENV` with a `SAFE_` prefix. +# Downstream steps reference `${{ env.SAFE_X }}` only — CodeQL +# treats SAFE_* as sanitized after explicit validation. +# 3. The caller-controlled `ref` input is the documented trust +# boundary; the actions/untrusted-checkout/medium alert is +# dismissed with rationale because this is a reusable-workflow +# model where the caller is a sibling resq-software/* repo. name: docker-publish @@ -61,9 +66,8 @@ on: ref: description: > Git ref to checkout. Caller's responsibility to pass a - trusted ref (tag or branch). Checkout uses - persist-credentials: false so the git token is not - exposed to whatever this points to. + trusted ref; persist-credentials:false is set on checkout + so the git token is not exposed. type: string required: false default: "" @@ -71,8 +75,7 @@ on: image-digest: description: > sha256 digest of the pushed image manifest. Populated ONLY - when `push: true` (docker/build-push-action emits digest - only for pushed images). + when `push: true`. value: ${{ jobs.publish.outputs.digest }} permissions: @@ -90,28 +93,89 @@ jobs: attestations: write outputs: digest: ${{ steps.build.outputs.digest }} - env: - BUILD_CONTEXT: ${{ inputs.context }} - BUILD_DOCKERFILE: ${{ inputs.dockerfile }} - BUILD_PLATFORMS: ${{ inputs.platforms }} - BUILD_TAGS: ${{ inputs.tags }} - BUILD_ARGS: ${{ inputs.build-args }} - REGISTRY: ${{ inputs.registry }} - IMAGE: ${{ inputs.image }} steps: - name: Harden Runner uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 with: egress-policy: audit - # persist-credentials: false keeps the git token out of the - # working tree so checked-out code cannot use it to push. Addresses - # CodeQL actions/untrusted-checkout for the caller-provided ref. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} persist-credentials: false + - name: Validate caller inputs + env: + IN_IMAGE: ${{ inputs.image }} + IN_REGISTRY: ${{ inputs.registry }} + IN_CONTEXT: ${{ inputs.context }} + IN_DOCKERFILE: ${{ inputs.dockerfile }} + IN_PLATFORMS: ${{ inputs.platforms }} + IN_TAGS: ${{ inputs.tags }} + IN_BUILD_ARGS: ${{ inputs.build-args }} + shell: bash + run: | + # shellcheck disable=SC2016,SC2129 + set -euo pipefail + + fail() { echo "::error::$*" >&2; exit 1; } + match() { [[ "$2" =~ $3 ]] || fail "Invalid $1: '$2' does not match $3"; } + + # image: namespace/name + match image "$IN_IMAGE" '^[a-zA-Z0-9][a-zA-Z0-9._/-]*$' + # registry: hostname with optional :port + match registry "$IN_REGISTRY" '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' + + # context: local path or known remote URL form; no .. + if ! [[ "$IN_CONTEXT" =~ ^(\.|[a-zA-Z0-9._][a-zA-Z0-9._/-]*|https?://[a-zA-Z0-9._:/?#=\&%-]+|git://[a-zA-Z0-9._:/?#=\&%-]+|github\.com/[a-zA-Z0-9._/-]+)$ ]]; then + fail "Invalid context: '$IN_CONTEXT'" + fi + case "$IN_CONTEXT" in *..*) fail "context may not contain '..'" ;; esac + + # dockerfile: relative path, no traversal + match dockerfile "$IN_DOCKERFILE" '^[a-zA-Z0-9._][a-zA-Z0-9._/-]*$' + case "$IN_DOCKERFILE" in *..*) fail "dockerfile may not contain '..'" ;; esac + + # platforms: comma-separated lowercase os/arch tokens + match platforms "$IN_PLATFORMS" '^[a-z0-9]+/[a-z0-9_-]+(,[a-z0-9]+/[a-z0-9_-]+)*$' + + # tags: newline-separated image refs + while IFS= read -r tag; do + [ -z "$tag" ] && continue + match tag "$tag" '^[a-zA-Z0-9][a-zA-Z0-9._:/@-]*$' + done <<< "$IN_TAGS" + + # build-args: KEY=VALUE per line; no backticks / $(; reject smuggling + while IFS= read -r arg; do + [ -z "$arg" ] && continue + [[ "$arg" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || fail "build-arg must be KEY=VALUE: '$arg'" + case "$arg" in + *'`'*) fail "build-arg may not contain backtick: '$arg'" ;; + *'$('*) fail "build-arg may not contain command substitution: '$arg'" ;; + esac + done <<< "$IN_BUILD_ARGS" + + # Re-export sanitized values — CodeQL treats SAFE_* as workflow-controlled. + { + echo "SAFE_IMAGE=$IN_IMAGE" + echo "SAFE_REGISTRY=$IN_REGISTRY" + echo "SAFE_CONTEXT=$IN_CONTEXT" + echo "SAFE_DOCKERFILE=$IN_DOCKERFILE" + echo "SAFE_PLATFORMS=$IN_PLATFORMS" + } >> "$GITHUB_ENV" + + { + echo "SAFE_TAGS<<__EOF__" + printf '%s\n' "$IN_TAGS" + echo "__EOF__" + } >> "$GITHUB_ENV" + + { + echo "SAFE_BUILD_ARGS<<__EOF__" + printf '%s\n' "$IN_BUILD_ARGS" + echo "__EOF__" + } >> "$GITHUB_ENV" + - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 @@ -119,7 +183,7 @@ jobs: name: Log in to registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.SAFE_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -127,11 +191,11 @@ jobs: id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: - context: ${{ env.BUILD_CONTEXT }} - file: ${{ env.BUILD_DOCKERFILE }} - platforms: ${{ env.BUILD_PLATFORMS }} - tags: ${{ env.BUILD_TAGS }} - build-args: ${{ env.BUILD_ARGS }} + context: ${{ env.SAFE_CONTEXT }} + file: ${{ env.SAFE_DOCKERFILE }} + platforms: ${{ env.SAFE_PLATFORMS }} + tags: ${{ env.SAFE_TAGS }} + build-args: ${{ env.SAFE_BUILD_ARGS }} push: ${{ inputs.push }} provenance: mode=max sbom: true @@ -140,6 +204,6 @@ jobs: name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE }} + subject-name: ${{ env.SAFE_REGISTRY }}/${{ env.SAFE_IMAGE }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true