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/cpp-ci.yml b/.github/workflows/cpp-ci.yml new file mode 100644 index 0000000..a3166be --- /dev/null +++ b/.github/workflows/cpp-ci.yml @@ -0,0 +1,86 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# Reusable C++ CI — CMake configure + build + test. Matrix over OS. +# 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 + +on: + workflow_call: + inputs: + os-list: + type: string + required: false + default: '["ubuntu-latest","macos-latest","windows-latest"]' + source-dir: + type: string + required: false + default: "." + build-dir: + type: string + required: false + default: "build" + cmake-flags: + 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 + shell: bash + env: + SRC: ${{ inputs.source-dir }} + BUILD: ${{ inputs.build-dir }} + FLAGS: ${{ inputs.cmake-flags }} + # 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: + BUILD: ${{ inputs.build-dir }} + CONFIG: ${{ inputs.build-config }} + run: cmake --build "$BUILD" --config "$CONFIG" + - if: ${{ inputs.run-test }} + name: Test + 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 new file mode 100644 index 0000000..ae4de33 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,209 @@ +# 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. +# +# Security posture: +# 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 + +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: + type: string + required: false + default: "." + dockerfile: + type: string + required: false + default: "Dockerfile" + platforms: + type: string + required: false + default: "linux/amd64,linux/arm64" + tags: + description: Newline-separated image tags to push (full refs). + type: string + required: true + build-args: + type: string + required: false + default: "" + push: + type: boolean + required: false + default: true + attest: + description: Emit SLSA build-provenance attestation (only runs when push is true). + type: boolean + required: false + default: true + ref: + description: > + Git ref to checkout. Caller's responsibility to pass a + trusted ref; persist-credentials:false is set on checkout + so the git token is not exposed. + type: string + required: false + default: "" + outputs: + image-digest: + description: > + sha256 digest of the pushed image manifest. Populated ONLY + when `push: true`. + 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 + id-token: write + attestations: write + 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 }} + 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 + + - if: ${{ inputs.push }} + name: Log in to registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.SAFE_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: ${{ 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 + + - if: ${{ inputs.attest && inputs.push }} + name: Attest build provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.SAFE_REGISTRY }}/${{ env.SAFE_IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml new file mode 100644 index 0000000..5eb3c26 --- /dev/null +++ b/.github/workflows/dotnet-ci.yml @@ -0,0 +1,131 @@ +# 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. +# +# 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 + +on: + workflow_call: + inputs: + dotnet-version: + type: string + required: false + default: "9.0.x" + solution: + description: Path to .sln or .csproj (empty = dotnet 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 + env: + SLN: ${{ inputs.solution }} + 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: | + # shellcheck disable=SC2086 + dotnet build $SLN -c "$CONFIG" --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 }} + - name: dotnet format + env: + SLN: ${{ inputs.solution }} + run: | + # shellcheck disable=SC2086 + dotnet format $SLN --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 }} + - name: dotnet test + env: + SLN: ${{ inputs.solution }} + CONFIG: ${{ inputs.configuration }} + run: | + # shellcheck disable=SC2086 + dotnet test $SLN -c "$CONFIG" --verbosity normal diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml new file mode 100644 index 0000000..d1a13c2 --- /dev/null +++ b/.github/workflows/node-ci.yml @@ -0,0 +1,137 @@ +# 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 `# `. +# +# 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 + +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. + type: string + required: false + default: "" + lint-cmd: + type: string + required: false + default: "" + typecheck-cmd: + type: string + required: false + default: "" + test-cmd: + type: string + required: false + default: "" + build-cmd: + 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 + + # 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: + 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' }} + + - name: Install + env: + PM: ${{ inputs.package-manager }} + INSTALL_CMD: ${{ inputs.install-cmd }} + run: | + set -eu + if [ -n "$INSTALL_CMD" ]; then + sh -c "$INSTALL_CMD" + else + 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 + env: + CMD: ${{ inputs.lint-cmd }} + run: sh -c "$CMD" + + - if: ${{ inputs.typecheck-cmd != '' }} + name: Typecheck + env: + CMD: ${{ inputs.typecheck-cmd }} + run: sh -c "$CMD" + + - if: ${{ inputs.build-cmd != '' }} + name: Build + env: + CMD: ${{ inputs.build-cmd }} + run: sh -c "$CMD" + + - if: ${{ inputs.test-cmd != '' }} + name: Test + env: + CMD: ${{ inputs.test-cmd }} + run: sh -c "$CMD" diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..cff9db7 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,176 @@ +# 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. +# +# Security: caller-controlled string inputs that reach `run:` are +# forwarded through `env:` and referenced as `"$VAR"`. + +name: python-ci + +on: + workflow_call: + inputs: + python-versions: + type: string + required: false + default: '["3.11","3.12","3.13"]' + default-python: + type: string + required: false + default: "3.13" + working-directory: + 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-paths: + description: Space-separated test paths. Empty = pytest discovers via pyproject.toml. + type: string + required: false + default: "" + test-flags: + type: string + required: false + default: "-v" + mypy-targets: + type: string + required: false + default: "src/" + ruff-targets: + 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 + - name: ruff check + env: + TARGETS: ${{ inputs.ruff-targets }} + run: | + # shellcheck disable=SC2086 + uv run ruff check $TARGETS + - name: ruff format check + env: + TARGETS: ${{ inputs.ruff-targets }} + run: | + # shellcheck disable=SC2086 + uv run ruff format --check $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 + - name: mypy + env: + TARGETS: ${{ inputs.mypy-targets }} + run: | + # shellcheck disable=SC2086 + uv run 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 + - name: pytest + env: + PATHS: ${{ inputs.test-paths }} + FLAGS: ${{ inputs.test-flags }} + run: | + # shellcheck disable=SC2086 + uv run pytest $PATHS $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..d6f89a3 --- /dev/null +++ b/.github/workflows/required.yml @@ -0,0 +1,215 @@ +# 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|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. +# +# For `lang: proto` repos, only security scanning runs — no language +# build is dispatched. + +name: required + +on: + workflow_call: + inputs: + lang: + description: One of rust|python|node|dotnet|cpp|proto|polyglot. + type: string + required: true + working-directory: + type: string + required: false + default: "." + codeql-languages: + 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"]' + 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 + security-events: write + 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: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2 + with: + egress-policy: audit + - 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: + languages: ${{ inputs.codeql-languages }} + enable-semgrep: ${{ inputs.enable-semgrep }} + enable-gitleaks: ${{ inputs.enable-gitleaks }} + secrets: inherit + + rust: + needs: [validate-lang] + 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: + needs: [validate-lang] + 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: + needs: [validate-lang] + 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 }} + + dotnet: + needs: [validate-lang] + 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: + needs: [validate-lang] + 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 }} + + # `required` is the single status-check context consumed by Ruleset A. + required: + name: required + needs: [validate-lang, security, rust, python, node, dotnet, cpp] + 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 }} + SECURITY_RESULT: ${{ needs.security.result }} + 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 + # validate-lang must be success — typo defenses 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 + 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..e267f6f --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,164 @@ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 +# +# 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 + +on: + workflow_call: + inputs: + toolchain: + type: string + required: false + default: stable + components: + type: string + required: false + default: rustfmt,clippy + working-directory: + 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: + type: boolean + required: false + default: false + run-coverage: + type: boolean + required: false + default: false + clippy-flags: + type: string + required: false + default: "--all-targets --all-features -- -D warnings" + test-flags: + 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 + - name: cargo clippy + env: + FLAGS: ${{ inputs.clippy-flags }} + run: | + # shellcheck disable=SC2086 + cargo 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 }} + name: cargo test + env: + FLAGS: ${{ inputs.test-flags }} + run: | + # shellcheck disable=SC2086 + cargo 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..1445bfe --- /dev/null +++ b/ops/governance-payloads.md @@ -0,0 +1,190 @@ +# Governance payloads + +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**. + +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 +``` + +--- + +## 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`. 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` + 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: + +```sh +gh api --method PATCH \ + -H "Accept: application/vnd.github+json" \ + /orgs/resq-software/properties/schema \ + --input ops/properties-schema.json +``` + +--- + +## 2. Property value assignments (`ops/properties-values.json`) + +**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 +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 (`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). + +**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: + +```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 (`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: + +```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: 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 - + +# 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/ +``` 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" } + ] +}