diff --git a/.github/actions/check-docs/action.yml b/.github/actions/check-docs/action.yml index 6581faf..63de648 100644 --- a/.github/actions/check-docs/action.yml +++ b/.github/actions/check-docs/action.yml @@ -8,16 +8,17 @@ runs: - name: Log run context shell: bash run: | - echo "Run context:" + echo "::group::Run context" echo " GITHUB_REF_NAME | ${GITHUB_REF_NAME}" echo " GITHUB_REF_SLUG | $(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | head -c 63 | sed 's/-$//')" echo " GITHUB_REF_TYPE | ${GITHUB_REF_TYPE}" echo " GITHUB_REPOSITORY | ${GITHUB_REPOSITORY}" echo " GITHUB_SERVER_URL | ${GITHUB_SERVER_URL}" echo " GITHUB_REPOSITORY_URL | ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" + echo "::endgroup::" - name: README check shell: bash run: | [ -f README.md ] || (echo "::error::A README.md file must be present at project root" && exit 1) - echo "README.md present" + echo "::notice::README.md present" diff --git a/.github/actions/javascript/base/action.yml b/.github/actions/javascript/base/action.yml index 66233a0..1590314 100644 --- a/.github/actions/javascript/base/action.yml +++ b/.github/actions/javascript/base/action.yml @@ -19,7 +19,7 @@ runs: exit 1 fi node_version="$(tr -d '[:space:]v' < .node-version)" - echo "Resolved Node.js version: ${node_version} (from .node-version)" + echo "::notice::Resolved Node.js version: ${node_version} (from .node-version)" echo "node-version=${node_version}" >> "${GITHUB_OUTPUT}" - name: Setup Node.js @@ -33,6 +33,10 @@ runs: corepack enable pnpm --version + - name: Install Socket Firewall + shell: bash + run: npm install -g sfw + - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: @@ -55,7 +59,8 @@ runs: - name: Install dependencies shell: bash - run: pnpm install --frozen-lockfile --ignore-scripts + # Fail-closed: no sfw, no install. + run: sfw pnpm install --frozen-lockfile --ignore-scripts - name: Lint shell: bash @@ -67,7 +72,7 @@ runs: if [ "$(jq .scripts.build package.json)" != "null" ]; then pnpm run build else - echo "Non required script 'build' skipped" + echo "::notice::Non required script 'build' skipped" fi - name: Test diff --git a/.github/actions/release/commit-artifacts/action.yml b/.github/actions/release/commit-artifacts/action.yml new file mode 100644 index 0000000..92518b4 --- /dev/null +++ b/.github/actions/release/commit-artifacts/action.yml @@ -0,0 +1,28 @@ +# Release Commit Artifacts Action Composite +name: release-commit-artifacts +description: Commit release artifacts back to main after a tagged publish. + +inputs: + files: + description: Space-separated artifact paths to stage and commit. + required: true + +runs: + using: composite + steps: + - name: Commit release artifacts back to main + shell: bash + env: + FILES: ${{ inputs.files }} + # [skip ci] keeps the bot's main push from re-triggering the pipeline. + run: | + read -ra paths <<< "${FILES}" + git add "${paths[@]}" + if git diff --staged --quiet; then + echo "::notice::No release artifacts to commit (nothing changed)" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore: release ${GITHUB_REF_NAME} [skip ci]" + git push origin HEAD:main + fi diff --git a/.github/actions/release/generate-changelog/action.yml b/.github/actions/release/generate-changelog/action.yml index 217f98f..42df8d7 100644 --- a/.github/actions/release/generate-changelog/action.yml +++ b/.github/actions/release/generate-changelog/action.yml @@ -33,10 +33,10 @@ runs: ' CHANGELOG.md 2>/dev/null || true)" if [ -n "${existing}" ]; then - echo "Using existing CHANGELOG section for ${tag}" + echo "::notice::Using existing CHANGELOG section for ${tag}" body="${existing}" else - echo "Auto-generating CHANGELOG section for ${tag}" + echo "::notice::Auto-generating CHANGELOG section for ${tag}" prev_tag="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)" commits_range="${prev_tag:+${prev_tag}..}HEAD" diff --git a/.github/actions/release/github-release/action.yml b/.github/actions/release/github-release/action.yml index 5e7a7fc..5dfc1db 100644 --- a/.github/actions/release/github-release/action.yml +++ b/.github/actions/release/github-release/action.yml @@ -6,6 +6,10 @@ inputs: body: description: Release notes body. required: true + draft: + description: Create the release as a draft. Binary repos undraft after assets upload; library repos leave it false. + required: false + default: "false" runs: using: composite @@ -15,10 +19,16 @@ runs: env: GH_TOKEN: ${{ github.token }} BODY: ${{ inputs.body }} + DRAFT: ${{ inputs.draft }} run: | notes="$(mktemp)" printf '%s' "${BODY}" > "${notes}" + draft_flag=() + if [ "${DRAFT}" = "true" ]; then + draft_flag=(--draft) + fi gh release create "${GITHUB_REF_NAME}" \ --title "${GITHUB_REF_NAME}" \ - --notes-file "${notes}" + --notes-file "${notes}" \ + "${draft_flag[@]}" rm -f "${notes}" diff --git a/.github/actions/release/verify-tag/action.yml b/.github/actions/release/verify-tag/action.yml new file mode 100644 index 0000000..f53779e --- /dev/null +++ b/.github/actions/release/verify-tag/action.yml @@ -0,0 +1,15 @@ +# Release Verify Tag Action Composite +name: release-verify-tag +description: Fail unless the checked-out main HEAD matches the tag SHA that triggered the run. + +runs: + using: composite + steps: + - name: Verify tag points to main HEAD + shell: bash + run: | + if [ "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]; then + echo "::error::main has moved since the tag was pushed — tag ${GITHUB_SHA}, main HEAD $(git rev-parse HEAD). Resolve manually." + exit 1 + fi + echo "::notice::main HEAD matches tag SHA (${GITHUB_SHA})" diff --git a/.github/actions/rust/base/action.yml b/.github/actions/rust/base/action.yml new file mode 100644 index 0000000..ee8d6f7 --- /dev/null +++ b/.github/actions/rust/base/action.yml @@ -0,0 +1,44 @@ +# Rust Base Action Composite +name: rust-base +description: Base setup for a Rust pipeline job. + +runs: + using: composite + steps: + - name: Install Rust toolchain + shell: bash + run: | + if [ ! -f rust-toolchain.toml ]; then + echo "::error::rust-toolchain.toml is required at the repo root" + exit 1 + fi + channel="$(grep -E '^[[:space:]]*channel[[:space:]]*=' rust-toolchain.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" + if [ -z "${channel}" ]; then + echo "::error::no channel found in rust-toolchain.toml" + exit 1 + fi + echo "::notice::Installing Rust toolchain: ${channel} (from rust-toolchain.toml)" + rustup toolchain install "${channel}" --profile minimal --component rustfmt --component clippy --no-self-update + + - name: Cache cargo + target + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + + - name: Native build dependencies + uses: coroboros/ci/.github/actions/rust/native-deps@v0 + + - name: Format + shell: bash + run: cargo fmt --check + + - name: Lint + shell: bash + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Test dependencies + uses: coroboros/ci/.github/actions/rust/test-deps@v0 + + - name: Test + shell: bash + run: cargo test --locked diff --git a/.github/actions/rust/install-dist/action.yml b/.github/actions/rust/install-dist/action.yml new file mode 100644 index 0000000..b283c89 --- /dev/null +++ b/.github/actions/rust/install-dist/action.yml @@ -0,0 +1,46 @@ +# Rust Install Dist Action Composite +name: rust-install-dist +description: Install cargo-dist's `dist` binary (prebuilt, SHA-256 verified) onto PATH. Linux, macOS, Windows. + +runs: + using: composite + steps: + - name: Install dist + shell: bash + env: + CARGO_DIST_VERSION: "0.32.0" + # https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/sha256.sum + SHA256_X86_64_LINUX: "eb52f9fae0d0506774e9f1801c1168f87fa2c87a45e2d64d3ae7c89401929946" + SHA256_AARCH64_LINUX: "d29bcffeb3f8b0c517b4ce0dd2470926ed5cb0bb29d78c6bdd5f88d76ee14a6a" + SHA256_X86_64_DARWIN: "6243464a8389e006b9256ee548bc795638f1a17113c1b6669c0e05ce89fd05c5" + SHA256_AARCH64_DARWIN: "aa343b2ff78ec2981f17a65140250c5ad6062c74072163f68c5c2686d94763a7" + SHA256_X86_64_WINDOWS: "26e845cabff12a92911ce960af73a86c8f9b2b2d9072b01dfe5b662acf044fa3" + run: | + set -euo pipefail + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) target="x86_64-unknown-linux-gnu"; sha="${SHA256_X86_64_LINUX}"; ext="tar.xz"; exe="" ;; + Linux-aarch64|Linux-arm64) target="aarch64-unknown-linux-gnu"; sha="${SHA256_AARCH64_LINUX}"; ext="tar.xz"; exe="" ;; + Darwin-x86_64) target="x86_64-apple-darwin"; sha="${SHA256_X86_64_DARWIN}"; ext="tar.xz"; exe="" ;; + Darwin-arm64) target="aarch64-apple-darwin"; sha="${SHA256_AARCH64_DARWIN}"; ext="tar.xz"; exe="" ;; + MINGW*-x86_64|MSYS*-x86_64|CYGWIN*-x86_64) target="x86_64-pc-windows-msvc"; sha="${SHA256_X86_64_WINDOWS}"; ext="zip"; exe=".exe" ;; + *) echo "::error::unsupported runner $(uname -s)-$(uname -m) for dist install"; exit 1 ;; + esac + asset="cargo-dist-${target}.${ext}" + tmp="$(mktemp -d)" + curl -fsSL "https://github.com/axodotdev/cargo-dist/releases/download/v${CARGO_DIST_VERSION}/${asset}" -o "${tmp}/${asset}" + # macOS runners ship `shasum`, not `sha256sum`; the dist-build matrix spans both. + if command -v sha256sum >/dev/null 2>&1; then + echo "${sha} ${tmp}/${asset}" | sha256sum -c - + else + echo "${sha} ${tmp}/${asset}" | shasum -a 256 -c - + fi + dest="${HOME}/.cargo/bin" + mkdir -p "${dest}" + if [ "${ext}" = "zip" ]; then + unzip -j -o "${tmp}/${asset}" "dist${exe}" -d "${dest}" + else + tar -xJf "${tmp}/${asset}" -C "${dest}" --strip-components=1 "cargo-dist-${target}/dist" + fi + rm -rf "${tmp}" + echo "${dest}" >> "${GITHUB_PATH}" + "${dest}/dist${exe}" --version diff --git a/.github/actions/rust/native-deps/action.yml b/.github/actions/rust/native-deps/action.yml new file mode 100644 index 0000000..215b60b --- /dev/null +++ b/.github/actions/rust/native-deps/action.yml @@ -0,0 +1,15 @@ +# Rust Native Dependencies Action Composite +name: rust-native-deps +description: Run the optional ci/setup.sh native build-dependency hook. + +runs: + using: composite + steps: + - name: Native build dependencies + shell: bash + run: | + if [ -f ci/setup.sh ]; then + bash ci/setup.sh + else + echo "::notice::No ci/setup.sh — pure-Rust package" + fi diff --git a/.github/actions/rust/pin-version/action.yml b/.github/actions/rust/pin-version/action.yml new file mode 100644 index 0000000..5ff7d33 --- /dev/null +++ b/.github/actions/rust/pin-version/action.yml @@ -0,0 +1,14 @@ +# Rust Pin Version Action Composite +name: rust-pin-version +description: Install cargo-set-version (version-pinned via CARGO_EDIT_VERSION) and stamp Cargo.toml to the release tag. + +runs: + using: composite + steps: + - name: Pin Cargo.toml to tag + shell: bash + env: + CARGO_EDIT_VERSION: "0.13.11" + run: | + cargo install cargo-edit --bin cargo-set-version --version "${CARGO_EDIT_VERSION}" --locked + cargo set-version "${GITHUB_REF_NAME}" diff --git a/.github/actions/rust/test-deps/action.yml b/.github/actions/rust/test-deps/action.yml new file mode 100644 index 0000000..f835715 --- /dev/null +++ b/.github/actions/rust/test-deps/action.yml @@ -0,0 +1,21 @@ +# Rust Test Dependencies Action Composite +name: rust-test-deps +description: Load the optional ci/test.env into the job environment and run the optional ci/test-setup.sh test-fixture hook. + +runs: + using: composite + steps: + - name: Test environment and fixtures + shell: bash + run: | + if [ -f ci/test.env ]; then + grep -E '^[A-Za-z_][A-Za-z0-9_]*=' ci/test.env >> "${GITHUB_ENV}" || true + echo "::notice::Loaded ci/test.env into the job environment" + else + echo "::notice::No ci/test.env — no test environment overrides" + fi + if [ -f ci/test-setup.sh ]; then + bash ci/test-setup.sh + else + echo "::notice::No ci/test-setup.sh — no test fixtures" + fi diff --git a/.github/actions/security/gitleaks/action.yml b/.github/actions/security/gitleaks/action.yml new file mode 100644 index 0000000..b4bac31 --- /dev/null +++ b/.github/actions/security/gitleaks/action.yml @@ -0,0 +1,70 @@ +# Security Gitleaks Action Composite +name: security-gitleaks +description: Install gitleaks (SHA-256 verified) and scan with the canonical Coroboros ruleset. Emits SARIF. + +# The caller checks out the repo to scan (fetch-depth: 0) before using this composite. +runs: + using: composite + steps: + - name: Checkout canonical gitleaks config + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: coroboros/ci + path: .coroboros-ci + sparse-checkout: | + security/.gitleaks.toml + sparse-checkout-cone-mode: false + + - name: Install gitleaks + shell: bash + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + run: | + tmp="$(mktemp -d)" + tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}" \ + -o "${tmp}/${tarball}" + echo "${GITLEAKS_SHA256} ${tmp}/${tarball}" | sha256sum -c - + tar -xzf "${tmp}/${tarball}" -C "${tmp}" gitleaks + sudo install -m 0755 "${tmp}/gitleaks" /usr/local/bin/gitleaks + rm -rf "${tmp}" + gitleaks version + + - name: Run gitleaks + shell: bash + env: + GITLEAKS_CONFIG: ".coroboros-ci/security/.gitleaks.toml" + SCAN_MODE: "git" + run: | + set +e + gitleaks "${SCAN_MODE}" \ + --config "${GITLEAKS_CONFIG}" \ + --no-banner \ + --redact \ + --report-format sarif \ + --report-path results.sarif \ + --exit-code 2 + rc=$? + set -e + + echo "::notice::gitleaks exit code: ${rc}" + if [ "${rc}" = "0" ]; then + echo "::notice::gitleaks: no leaks found" + elif [ "${rc}" = "2" ]; then + echo "::error::gitleaks: leaks detected — see results.sarif artifact" + exit 1 + else + echo "::error::gitleaks: scan failed with exit code ${rc}" + exit "${rc}" + fi + + - name: Upload SARIF report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: gitleaks-report + path: results.sarif + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/actions/security/osv-scanner/action.yml b/.github/actions/security/osv-scanner/action.yml new file mode 100644 index 0000000..6a596cd --- /dev/null +++ b/.github/actions/security/osv-scanner/action.yml @@ -0,0 +1,43 @@ +# Security OSV Scanner Action Composite +name: security-osv-scanner +description: Scan dependency manifests for known vulnerabilities (OSV.dev). Skips a repo with none; fails on a known vulnerability. + +runs: + using: composite + steps: + - id: detect + shell: bash + # osv-scanner errors when it finds no manifest. Gate it on a file osv can + # resolve, so a dependency-less repo (docs, config, this CI repo itself) + # wiring in security.yml skips the scan instead of failing on it. A real + # dependency repo carries one of these — extend the list as ecosystems land. + run: | + manifests=( + package-lock.json pnpm-lock.yaml yarn.lock bun.lock + Cargo.lock + go.mod + requirements.txt poetry.lock Pipfile.lock pdm.lock uv.lock + Gemfile.lock + composer.lock + ) + found="" + for m in "${manifests[@]}"; do + if [ -n "$(find . -name "${m}" -not -path '*/node_modules/*' -not -path '*/.git/*' -print -quit)" ]; then + found="${m}" + break + fi + done + if [ -n "${found}" ]; then + echo "::notice::osv-scanner: ${found} present — scanning" + echo "scan=true" >> "${GITHUB_OUTPUT}" + else + echo "::notice::osv-scanner: no supported manifest — skipping (nothing to scan)" + echo "scan=false" >> "${GITHUB_OUTPUT}" + fi + + - if: ${{ steps.detect.outputs.scan == 'true' }} + uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: |- + --recursive + ./ diff --git a/.github/actions/security/rust/cargo-deny/action.yml b/.github/actions/security/rust/cargo-deny/action.yml new file mode 100644 index 0000000..2d5471c --- /dev/null +++ b/.github/actions/security/rust/cargo-deny/action.yml @@ -0,0 +1,39 @@ +# Security Rust Cargo Deny Action Composite +name: security-rust-cargo-deny +description: Run cargo-deny against the canonical Coroboros ruleset (imposed, no consumer override). `checks` selects which cargo-deny checks run; empty runs all. + +inputs: + checks: + description: "Space-separated cargo-deny checks to run (e.g. 'advisories bans sources' for the gate, 'licenses' for the advisory layer). Empty runs all." + required: false + default: "" + +# The caller checks out the repo to scan before using this composite. +runs: + using: composite + steps: + - name: Checkout canonical deny config + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: coroboros/ci + path: .coroboros-ci + sparse-checkout: | + security/deny.toml + sparse-checkout-cone-mode: false + + - name: Reject consumer deny overrides + shell: bash + # `--config` ignores the consumer's deny.toml, but cargo-deny still merges a + # project-local deny.exceptions.toml into the licenses policy — block it. + run: | + if find . -path ./.coroboros-ci -prune -o -type f \ + \( -name 'deny.exceptions.toml' -o -name '.deny.exceptions.toml' \) -print \ + | grep -q .; then + echo "::error::deny.exceptions.toml is not permitted — the cargo-deny policy is imposed by coroboros/ci" + exit 1 + fi + + - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20 + with: + command: check + command-arguments: "--config .coroboros-ci/security/deny.toml ${{ inputs.checks }}" diff --git a/.github/renovate/sync-tool-sha.sh b/.github/renovate/sync-tool-sha.sh new file mode 100644 index 0000000..3581285 --- /dev/null +++ b/.github/renovate/sync-tool-sha.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Re-sync each pinned tarball SHA-256 to its current pinned version, in place. Idempotent. +# Run as a Renovate postUpgradeTask after a version bump so the version and its checksum land +# in the same PR. Executes at the repo root inside the Renovate container (curl, sha256sum, +# sed, awk, grep all present). Renovate gates it via RENOVATE_ALLOWED_COMMANDS. +set -euo pipefail + +GITLEAKS_YML=".github/actions/security/gitleaks/action.yml" +SELF_YML=".github/workflows/self.yml" +DIST_YML=".github/actions/rust/install-dist/action.yml" + +sha256_of() { + local tmp; tmp="$(mktemp)" + curl -fsSL "$1" -o "$tmp" + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$tmp" | cut -d' ' -f1 + else shasum -a 256 "$tmp" | cut -d' ' -f1; fi +} +ver() { grep -E "$2:" "$1" | head -1 | sed -E 's/.*"([^"]+)".*/\1/'; } +set_sha() { sed -i -E "s|($2: *\")[^\"]+(\")|\1${3}\2|" "$1"; } + +# gitleaks — single linux x64 tarball +v="$(ver "$GITLEAKS_YML" GITLEAKS_VERSION)" +set_sha "$GITLEAKS_YML" GITLEAKS_SHA256 \ + "$(sha256_of "https://github.com/gitleaks/gitleaks/releases/download/v${v}/gitleaks_${v}_linux_x64.tar.gz")" + +# actionlint — single linux amd64 tarball +v="$(ver "$SELF_YML" ACTIONLINT_VERSION)" +set_sha "$SELF_YML" ACTIONLINT_SHA256 \ + "$(sha256_of "https://github.com/rhysd/actionlint/releases/download/v${v}/actionlint_${v}_linux_amd64.tar.gz")" + +# cargo-dist — five per-OS archives, read from the release's own sha256.sum +v="$(ver "$DIST_YML" CARGO_DIST_VERSION)" +sums="$(curl -fsSL "https://github.com/axodotdev/cargo-dist/releases/download/v${v}/sha256.sum")" +pick() { grep -F "cargo-dist-$1" <<<"$sums" | awk '{print $1}'; } +set_sha "$DIST_YML" SHA256_X86_64_LINUX "$(pick x86_64-unknown-linux-gnu.tar.xz)" +set_sha "$DIST_YML" SHA256_AARCH64_LINUX "$(pick aarch64-unknown-linux-gnu.tar.xz)" +set_sha "$DIST_YML" SHA256_X86_64_DARWIN "$(pick x86_64-apple-darwin.tar.xz)" +set_sha "$DIST_YML" SHA256_AARCH64_DARWIN "$(pick aarch64-apple-darwin.tar.xz)" +set_sha "$DIST_YML" SHA256_X86_64_WINDOWS "$(pick x86_64-pc-windows-msvc.zip)" + +echo "::notice::tool SHA-256 values re-synced to their pinned versions" diff --git a/.github/workflows/javascript-npm-packages.yml b/.github/workflows/javascript-npm-packages.yml index c2b905e..d881a4f 100644 --- a/.github/workflows/javascript-npm-packages.yml +++ b/.github/workflows/javascript-npm-packages.yml @@ -18,6 +18,12 @@ on: permissions: contents: read +# Tags serialize per repo so one release's commit-back can't race another's; branches key +# per ref and cancel superseded runs, never an in-flight release. +concurrency: + group: release-${{ github.ref_type == 'tag' && github.repository || github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + env: NPM_CONFIG_FILE: ${{ secrets.NPM_CONFIG_FILE }} NPM_EXTRA_CONFIG: ${{ secrets.NPM_EXTRA_CONFIG }} @@ -34,8 +40,12 @@ jobs: - uses: coroboros/ci/.github/actions/check-docs@v0 - uses: coroboros/ci/.github/actions/javascript/base@v0 + security-gate: + uses: ./.github/workflows/security-gate.yml + publish: if: ${{ github.ref_type == 'tag' }} + needs: [security-gate] # no release ships until the security gate passes runs-on: ubuntu-latest permissions: contents: write # for GitHub Release creation + commit-back to main @@ -46,16 +56,7 @@ jobs: ref: main fetch-depth: 0 - - name: Verify tag points to main HEAD - shell: bash - run: | - if [ "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]; then - echo "::error::main has moved since the tag was pushed. Resolve manually." - echo " Tag SHA: ${GITHUB_SHA}" - echo " main HEAD: $(git rev-parse HEAD)" - exit 1 - fi - echo "main HEAD matches tag SHA (${GITHUB_SHA})" + - uses: coroboros/ci/.github/actions/release/verify-tag@v0 - uses: coroboros/ci/.github/actions/check-docs@v0 - uses: coroboros/ci/.github/actions/javascript/base@v0 @@ -71,7 +72,7 @@ jobs: shell: bash run: | if [ -n "${NPM_PACKAGE_REGISTRY_TOKEN}" ]; then - echo "Publishing with NPM_PACKAGE_REGISTRY_TOKEN auth via npm CLI" + echo "::notice::Publishing with NPM_PACKAGE_REGISTRY_TOKEN auth via npm CLI" # The pre-Trusted-Publisher bootstrap path can't reliably use # pnpm 11.x (auto-OIDC, no fallback to .npmrc token) or pnpm # 10.33.0 (corepack intercepts every `pnpm`; the standalone @@ -84,7 +85,7 @@ jobs: npm --version npm publish --ignore-scripts --access public else - echo "Publishing with OIDC Trusted Publisher + provenance" + echo "::notice::Publishing with OIDC Trusted Publisher + provenance" pnpm publish --provenance --no-git-checks --ignore-scripts fi @@ -92,28 +93,9 @@ jobs: with: body: ${{ steps.changelog.outputs.body }} - - name: Commit release artifacts back to main - shell: bash - run: | - git add CHANGELOG.md package.json pnpm-lock.yaml - if git diff --staged --quiet; then - echo "::notice::No release artifacts to commit (nothing changed)" - else - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore: release ${GITHUB_REF_NAME}" - git push origin HEAD:main - fi - - - name: Move rolling major tag - if: ${{ !contains(github.ref_name, '-') }} - shell: bash - run: | - major="$(echo "${GITHUB_REF_NAME}" | cut -d. -f1)" - rolling="v${major}" - git tag -f "${rolling}" HEAD - git push -f origin "${rolling}" - echo "::notice::Moved rolling tag ${rolling} to $(git rev-parse HEAD)" + - uses: coroboros/ci/.github/actions/release/commit-artifacts@v0 + with: + files: CHANGELOG.md package.json pnpm-lock.yaml security: uses: ./.github/workflows/security.yml diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml new file mode 100644 index 0000000..bb1ec0e --- /dev/null +++ b/.github/workflows/renovate.yml @@ -0,0 +1,27 @@ +# Renovate +name: Renovate + +# Self-hosted Renovate bumps the version-pinned tooling and re-syncs each paired +# tarball SHA-256 in the same PR (postUpgradeTask). The RENOVATE_TOKEN PAT is +# required so its PRs trigger the rest of self-CI, which GITHUB_TOKEN can't. Scope +# in CLAUDE.md. +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + renovate: + runs-on: ubuntu-latest + steps: + - uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14 + with: + token: ${{ secrets.RENOVATE_TOKEN }} + env: + RENOVATE_REPOSITORIES: '["coroboros/ci"]' + # Gate the SHA-resync postUpgradeTask; the regex must match the exact resolved command. + RENOVATE_ALLOWED_COMMANDS: '["^bash \\.github/renovate/sync-tool-sha\\.sh$"]' + LOG_LEVEL: "info" diff --git a/.github/workflows/rust-packages.yml b/.github/workflows/rust-packages.yml new file mode 100644 index 0000000..d212a54 --- /dev/null +++ b/.github/workflows/rust-packages.yml @@ -0,0 +1,342 @@ +# Rust Packages CI +name: Rust Packages + +on: + workflow_call: + secrets: + CARGO_REGISTRY_TOKEN: + required: false + HOMEBREW_TAP_TOKEN: + required: false + NPM_PACKAGE_REGISTRY_TOKEN: + required: false + +permissions: + contents: read + +# Tags serialize per repo so one release's commit-back can't race another's; branches key +# per ref and cancel superseded runs, never an in-flight release. +concurrency: + group: release-${{ github.ref_type == 'tag' && github.repository || github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +jobs: + preflight: + if: ${{ github.ref_type == 'branch' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: coroboros/ci/.github/actions/check-docs@v0 + - uses: coroboros/ci/.github/actions/rust/base@v0 + + security-gate: + uses: ./.github/workflows/security-gate.yml + + package: + if: ${{ github.ref_type == 'branch' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: coroboros/ci/.github/actions/rust/native-deps@v0 + - name: Verify the published crate builds + shell: bash + run: cargo package --locked + + dist-plan: + if: ${{ github.ref_type == 'tag' }} + runs-on: ubuntu-latest + outputs: + enabled: ${{ steps.detect.outputs.enabled }} + matrix: ${{ steps.plan.outputs.matrix }} + tap: ${{ steps.detect.outputs.tap }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.sha }} + + - id: detect + name: Detect cargo-dist metadata + shell: bash + run: | + # cargo-dist 0.32 keeps its global keys in [workspace.metadata.dist]; detect either table. + if grep -qE '^\[(package|workspace)\.metadata\.dist\]' Cargo.toml 2>/dev/null; then + echo "::notice::cargo-dist metadata present — binary distribution enabled" + tap="$(grep -E '^[[:space:]]*tap[[:space:]]*=' Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" + { + echo "enabled=true" + echo "tap=${tap}" + } >> "${GITHUB_OUTPUT}" + else + echo "::notice::no cargo-dist metadata — binary jobs skip" + { + echo "enabled=false" + echo "tap=" + } >> "${GITHUB_OUTPUT}" + fi + + - if: ${{ steps.detect.outputs.enabled == 'true' }} + uses: coroboros/ci/.github/actions/rust/install-dist@v0 + + - if: ${{ steps.detect.outputs.enabled == 'true' }} + uses: coroboros/ci/.github/actions/rust/pin-version@v0 + + - id: plan + if: ${{ steps.detect.outputs.enabled == 'true' }} + name: Compute build matrix + shell: bash + run: | + dist plan --tag="${GITHUB_REF_NAME}" --output-format=json > plan-dist-manifest.json + echo "matrix=$(jq -c '.ci.github.artifacts_matrix' plan-dist-manifest.json)" >> "${GITHUB_OUTPUT}" + + - if: ${{ steps.detect.outputs.enabled == 'true' }} + name: Upload plan manifest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dist-plan-manifest + path: plan-dist-manifest.json + if-no-files-found: error + + dist-build: + needs: [dist-plan, security-gate] # no artifact builds until the gate passes + if: ${{ needs.dist-plan.outputs.enabled == 'true' }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.dist-plan.outputs.matrix) }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + # The resolved target triple(s) reach the consumer's target-aware ci/setup.sh via native-deps. + env: + CARGO_DIST_TARGET: "${{ join(matrix.targets, ' ') }}" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.sha }} + + - name: Cache cargo + target + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: ${{ join(matrix.targets, '_') }} + + - uses: coroboros/ci/.github/actions/rust/install-dist@v0 + + - uses: coroboros/ci/.github/actions/rust/pin-version@v0 + + - uses: coroboros/ci/.github/actions/rust/native-deps@v0 + + - name: Install build system dependencies + if: ${{ matrix.packages_install }} + shell: bash + run: ${{ matrix.packages_install }} + + # matrix.install_dist (dist's curl|sh installer) is ignored — dist is installed version-pinned above. + - name: Build local artifacts + shell: bash + run: dist build --tag="${GITHUB_REF_NAME}" ${{ matrix.dist_args }} + + - name: Upload local artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dist-build-${{ join(matrix.targets, '_') }} + path: target/distrib/ + if-no-files-found: error + + publish: + if: ${{ github.ref_type == 'tag' && !cancelled() && needs.security-gate.result == 'success' && needs.dist-plan.result == 'success' && needs.dist-build.result != 'failure' }} + needs: [security-gate, dist-plan, dist-build] # gate must pass; a failed binary build blocks publish (skipped dist-build = library crate, still publishes) + runs-on: ubuntu-latest + permissions: + contents: write # for GitHub Release creation + commit-back to main + id-token: write # for crates.io OIDC Trusted Publishing + # Surfaced as env so the OIDC auth step can branch on the token's presence. + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: main + fetch-depth: 0 + + - uses: coroboros/ci/.github/actions/release/verify-tag@v0 + + - uses: coroboros/ci/.github/actions/check-docs@v0 + - uses: coroboros/ci/.github/actions/rust/base@v0 + + - uses: coroboros/ci/.github/actions/rust/pin-version@v0 + + - id: changelog + uses: coroboros/ci/.github/actions/release/generate-changelog@v0 + + - name: Mint a short-lived crates.io token via OIDC + id: auth + if: ${{ env.CARGO_REGISTRY_TOKEN == '' }} + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 + + - name: Publish to crates.io + shell: bash + env: + # OIDC path: the short-lived token from the auth step. Bootstrap path: + # the long-lived CARGO_REGISTRY_TOKEN secret (already in job env). + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token || env.CARGO_REGISTRY_TOKEN }} + run: | + # --allow-dirty covers the version pin (cargo set-version) + changelog regen; assert + # nothing else is dirty so no stray file ships to the immutable crates.io release. + unexpected="$(git status --porcelain | grep -vE '^.. (Cargo\.toml|Cargo\.lock|CHANGELOG\.md)$' || true)" + if [ -n "${unexpected}" ]; then + echo "::error::unexpected uncommitted changes before publish — only Cargo.toml/Cargo.lock/CHANGELOG.md may be dirty:" + printf '%s\n' "${unexpected}" + exit 1 + fi + cargo publish --allow-dirty + + - uses: coroboros/ci/.github/actions/release/github-release@v0 + with: + body: ${{ steps.changelog.outputs.body }} + draft: ${{ needs.dist-plan.outputs.enabled }} # draft for binary repos; dist-host undrafts + + - uses: coroboros/ci/.github/actions/release/commit-artifacts@v0 + with: + files: Cargo.toml Cargo.lock CHANGELOG.md + + dist-host: + needs: [dist-plan, dist-build, publish] + if: ${{ needs.dist-plan.outputs.enabled == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write # upload release assets + undraft the release publish created + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.sha }} + + - uses: coroboros/ci/.github/actions/rust/install-dist@v0 + + - uses: coroboros/ci/.github/actions/rust/pin-version@v0 + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: dist-build-* + path: target/distrib/ + merge-multiple: true + + # The global build reads the same manifest `dist plan` produced (URL derivation). + - name: Download plan manifest + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist-plan-manifest + path: target/distrib/ + + # Final asset URLs are deterministic (repo + tag), so --tag alone embeds non-draft links — no dist-owned release. + - name: Build global artifacts + shell: bash + run: dist build --tag="${GITHUB_REF_NAME}" --artifacts=global --output-format=json > target/distrib/dist-manifest.json + + # Undraft before the formula/npm job so they resolve against a live release, not a draft. + - name: Upload release assets and undraft + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + shopt -s nullglob + assets=() + for f in target/distrib/*; do + case "${f}" in + *.json) ;; + *) [ -f "${f}" ] && assets+=("${f}") ;; + esac + done + if [ "${#assets[@]}" -gt 0 ]; then + gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" --clobber + fi + gh release edit "${GITHUB_REF_NAME}" --draft=false + + - name: Upload global artifacts for publish + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dist-global + path: | + target/distrib/*.rb + target/distrib/*-npm-package.tar.gz + if-no-files-found: ignore + + dist-publish: + needs: [dist-plan, dist-host] + if: ${{ needs.dist-plan.outputs.enabled == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm OIDC provenance + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + steps: + - name: Download global artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist-global + path: dist-global + + - name: Checkout Homebrew tap + if: ${{ env.HOMEBREW_TAP_TOKEN != '' && needs.dist-plan.outputs.tap != '' }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: ${{ needs.dist-plan.outputs.tap }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap + + - name: Publish Homebrew formula + if: ${{ env.HOMEBREW_TAP_TOKEN != '' && needs.dist-plan.outputs.tap != '' }} + shell: bash + run: | + shopt -s nullglob + formulas=(dist-global/*.rb) + if [ "${#formulas[@]}" -eq 0 ]; then + echo "::notice::no Homebrew formula generated — skipping" + exit 0 + fi + git -C homebrew-tap config user.name "github-actions[bot]" + git -C homebrew-tap config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdir -p homebrew-tap/Formula + for f in "${formulas[@]}"; do + cp "${f}" "homebrew-tap/Formula/$(basename "${f}")" + git -C homebrew-tap add "Formula/$(basename "${f}")" + done + git -C homebrew-tap commit -m "release ${GITHUB_REF_NAME}" + # Concurrent releases of different repos push to the shared tap; rebase-retry on contention. + for attempt in 1 2 3 4 5; do + git -C homebrew-tap push && break + if [ "${attempt}" -eq 5 ]; then + echo "::error::Homebrew tap push failed after ${attempt} attempts" + exit 1 + fi + echo "::warning::tap push rejected (attempt ${attempt}) — rebasing and retrying" + git -C homebrew-tap pull --rebase + done + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Publish npm shim + shell: bash + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }} + run: | + shopt -s nullglob + shims=(dist-global/*-npm-package.tar.gz) + if [ "${#shims[@]}" -eq 0 ]; then + echo "::notice::no npm shim generated — skipping" + exit 0 + fi + for pkg in "${shims[@]}"; do + # Provenance attests via the job's id-token on both auth paths — token bootstrap or OIDC. + npm publish --provenance --access public "${pkg}" + done + + security: + uses: ./.github/workflows/security.yml diff --git a/.github/workflows/security-gate.yml b/.github/workflows/security-gate.yml new file mode 100644 index 0000000..eadd65b --- /dev/null +++ b/.github/workflows/security-gate.yml @@ -0,0 +1,44 @@ +# Security Gate CI +name: Security Gate + +on: + workflow_call: + +permissions: + contents: read + +jobs: + supply-chain: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - id: detect + name: Route supply-chain scan by ecosystem + shell: bash + # One tool per repo, never both — cargo-deny covers a Rust crate's vulns, so osv + # must not double-scan it. + run: | + if [ -f Cargo.toml ]; then + echo "::notice::Cargo.toml present — supply-chain via cargo-deny" + echo "ecosystem=rust" >> "${GITHUB_OUTPUT}" + else + echo "::notice::no Cargo.toml — supply-chain via osv-scanner" + echo "ecosystem=other" >> "${GITHUB_OUTPUT}" + fi + + - if: ${{ steps.detect.outputs.ecosystem == 'rust' }} + uses: coroboros/ci/.github/actions/security/rust/cargo-deny@v0 + with: + checks: "advisories bans sources" + + - if: ${{ steps.detect.outputs.ecosystem == 'other' }} + uses: coroboros/ci/.github/actions/security/osv-scanner@v0 + + secret-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: coroboros/ci/.github/actions/security/gitleaks@v0 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 86192e1..7770658 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -7,80 +7,7 @@ on: permissions: contents: read -env: - GITLEAKS_VERSION: "8.30.1" - GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" - GITLEAKS_CONFIG: ".coroboros-ci/security/.gitleaks.toml" - SCAN_MODE: "git" - jobs: - gitleaks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - name: Checkout canonical gitleaks config - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - repository: coroboros/ci - path: .coroboros-ci - sparse-checkout: | - security/.gitleaks.toml - sparse-checkout-cone-mode: false - - - name: Install gitleaks - shell: bash - run: | - tmp="$(mktemp -d)" - tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" - curl -fsSL \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}" \ - -o "${tmp}/${tarball}" - echo "${GITLEAKS_SHA256} ${tmp}/${tarball}" | sha256sum -c - - tar -xzf "${tmp}/${tarball}" -C "${tmp}" gitleaks - sudo install -m 0755 "${tmp}/gitleaks" /usr/local/bin/gitleaks - rm -rf "${tmp}" - gitleaks version - - - name: Run gitleaks - id: scan - shell: bash - run: | - set +e - gitleaks "${SCAN_MODE}" \ - --config "${GITLEAKS_CONFIG}" \ - --no-banner \ - --redact \ - --report-format sarif \ - --report-path results.sarif \ - --exit-code 2 - rc=$? - set -e - - echo "gitleaks exit code: ${rc}" - echo "exit-code=${rc}" >> "${GITHUB_OUTPUT}" - - if [ "${rc}" = "0" ]; then - echo "::notice::gitleaks: no leaks found" - elif [ "${rc}" = "2" ]; then - echo "::error::gitleaks: leaks detected — see results.sarif artifact" - exit 1 - else - echo "::error::gitleaks: scan failed with exit code ${rc}" - exit "${rc}" - fi - - - name: Upload SARIF report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: gitleaks-report - path: results.sarif - if-no-files-found: ignore - retention-days: 30 - dependency-review: if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest @@ -91,12 +18,23 @@ jobs: fail-on-severity: high comment-summary-in-pr: never - osv-scanner: + licenses: runs-on: ubuntu-latest + # Advisory — reports a non-allowed license, never blocks the release. + continue-on-error: true steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + - id: detect + name: Detect a Rust manifest + shell: bash + run: | + if [ -f Cargo.toml ]; then + echo "rust=true" >> "${GITHUB_OUTPUT}" + else + echo "::notice::no Cargo.toml — license check skipped (Rust-only)" + echo "rust=false" >> "${GITHUB_OUTPUT}" + fi + - if: ${{ steps.detect.outputs.rust == 'true' }} + uses: coroboros/ci/.github/actions/security/rust/cargo-deny@v0 with: - scan-args: |- - --recursive - ./ + checks: "licenses" diff --git a/.github/workflows/self-actions.yml b/.github/workflows/self-actions.yml new file mode 100644 index 0000000..ee87e25 --- /dev/null +++ b/.github/workflows/self-actions.yml @@ -0,0 +1,201 @@ +# Self-CI Actions +name: Self-CI Actions + +# A PR self-tests its composites via local `./` refs against the real checkout +# (GITHUB_SHA == HEAD); `commit-artifacts` pushes to a local bare remote, with +# `contents: read` as the backstop, so no real branch is touched. No secrets. +# Tag-driven paths can't be faked here — the runner re-injects GITHUB_REF_NAME / +# GITHUB_SHA inside a composite — so generate-changelog and pin-version stay +# validated at real release time. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + verify-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Pass — HEAD matches the run SHA + uses: ./.github/actions/release/verify-tag + - name: Move HEAD so it diverges from the run SHA + shell: bash + run: | + set -euo pipefail + git config user.email "ci@ci"; git config user.name "ci"; git config commit.gpgsign false + git commit -q --allow-empty -m "smoke: move HEAD" + - name: Fail — HEAD no longer matches + id: moved + continue-on-error: true + uses: ./.github/actions/release/verify-tag + - name: Assert the moved branch failed + shell: bash + run: | + set -euo pipefail + [ "${{ steps.moved.outcome }}" = "failure" ] || { echo "::error::verify-tag must fail when HEAD != GITHUB_SHA"; exit 1; } + echo "::notice::verify-tag passes on match, fails on divergence" + + generate-changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: SemVer gate rejects a non-tag ref + id: gate + continue-on-error: true + uses: ./.github/actions/release/generate-changelog + - name: Assert the gate rejected it + shell: bash + run: | + set -euo pipefail + [ "${{ steps.gate.outcome }}" = "failure" ] || { echo "::error::SemVer gate must reject the non-SemVer ref '${GITHUB_REF_NAME}'"; exit 1; } + echo "::notice::generate-changelog SemVer gate rejects non-tag refs" + + commit-artifacts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + path: _src + - name: Build a fixture repo + local bare remote + shell: bash + run: | + set -euo pipefail + # Throwaway non-shallow repo at the workspace root, where the composite's run executes. + # The PR's own checkout is shallow (push rejected) and lives under _src. GITHUB_REF_NAME + # is the real ref here — it only lands in the commit subject, asserted loosely below. + remote="${RUNNER_TEMP}/origin.git"; git init -q --bare "${remote}" + git init -q -b main + git config user.email "ci@ci"; git config user.name "ci"; git config commit.gpgsign false + git remote add origin "${remote}" + git commit -q --allow-empty -m base + git push -q origin HEAD:refs/heads/main + printf 'a\n' > art1.txt; printf 'b\n' > art2.txt + echo "REMOTE=${remote}" >> "${GITHUB_ENV}" + - name: Changed → commit + push (FILES word-split) + uses: ./_src/.github/actions/release/commit-artifacts + with: + files: art1.txt art2.txt + - name: Assert the remote advanced with both files + shell: bash + run: | + set -euo pipefail + git --git-dir="${REMOTE}" log -1 --pretty=%s main | grep -qE '^chore: release .+ \[skip ci\]$' \ + || { echo "::error::commit-artifacts subject malformed"; exit 1; } + tree="$(git --git-dir="${REMOTE}" ls-tree --name-only main)" + grep -qx 'art1.txt' <<<"${tree}" || { echo "::error::art1.txt missing from pushed tree"; exit 1; } + grep -qx 'art2.txt' <<<"${tree}" || { echo "::error::art2.txt missing from pushed tree (FILES word-split)"; exit 1; } + echo "BEFORE=$(git --git-dir="${REMOTE}" rev-parse main)" >> "${GITHUB_ENV}" + echo "::notice::commit-artifacts pushed both artifacts" + - name: Nothing changed → no-op + uses: ./_src/.github/actions/release/commit-artifacts + with: + files: art1.txt + - name: Assert the no-op left the remote untouched + shell: bash + run: | + set -euo pipefail + [ "${BEFORE}" = "$(git --git-dir="${REMOTE}" rev-parse main)" ] \ + || { echo "::error::no-op branch pushed unexpectedly"; exit 1; } + echo "::notice::commit-artifacts no-op left main untouched" + + cargo-deny-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Plant a forbidden consumer override + shell: bash + run: | + set -euo pipefail + : > deny.exceptions.toml + - name: Composite must reject deny.exceptions.toml + id: reject + continue-on-error: true + uses: ./.github/actions/security/rust/cargo-deny + - name: Assert the reject guard fired + shell: bash + run: | + set -euo pipefail + [ "${{ steps.reject.outcome }}" = "failure" ] || { echo "::error::cargo-deny must reject a consumer deny.exceptions.toml"; exit 1; } + echo "::notice::cargo-deny rejects consumer deny.exceptions.toml" + + install-dist: + # cargo-dist packages the Windows zip flat (dist.exe at root) but the Linux/macOS + # tarballs nested — extraction differs per OS, so the smoke covers all three. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/rust/install-dist + - name: Assert dist is installed and runnable + shell: bash + run: | + set -euo pipefail + dist --version || { echo "::error::dist not on PATH after install-dist"; exit 1; } + echo "::notice::install-dist OK — $(dist --version)" + + native-deps-target: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Plant a fixture ci/setup.sh that records CARGO_DIST_TARGET + shell: bash + run: | + set -euo pipefail + mkdir -p ci + cat > ci/setup.sh <<'SH' + #!/usr/bin/env bash + echo "${CARGO_DIST_TARGET-}" > seen-target.txt + SH + - name: Host preflight — CARGO_DIST_TARGET unset + uses: ./.github/actions/rust/native-deps + - name: Assert the host hook ran and saw an empty target + shell: bash + run: | + set -euo pipefail + [ -f seen-target.txt ] || { echo "::error::ci/setup.sh did not run on host preflight"; exit 1; } + [ -z "$(cat seen-target.txt)" ] || { echo "::error::CARGO_DIST_TARGET must be empty on host preflight"; exit 1; } + - name: Export the target the way dist-build does + shell: bash + run: echo "CARGO_DIST_TARGET=aarch64-unknown-linux-gnu" >> "${GITHUB_ENV}" + - name: Cross leg — CARGO_DIST_TARGET exported + uses: ./.github/actions/rust/native-deps + - name: Assert the hook saw the exported target + shell: bash + run: | + set -euo pipefail + got="$(cat seen-target.txt)" + [ "${got}" = "aarch64-unknown-linux-gnu" ] || { echo "::error::ci/setup.sh saw '${got}', expected the exported target"; exit 1; } + echo "::notice::native-deps passes CARGO_DIST_TARGET through to ci/setup.sh" + + test-deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Absent hooks → no-op + uses: ./.github/actions/rust/test-deps + - name: Plant ci/test.env and ci/test-setup.sh + shell: bash + run: | + set -euo pipefail + mkdir -p ci + printf 'FOO=1\n' > ci/test.env + cat > ci/test-setup.sh <<'SH' + #!/usr/bin/env bash + touch test-setup-ran + SH + - name: Run the test hooks + uses: ./.github/actions/rust/test-deps + - name: Assert fixtures ran and test.env propagated + shell: bash + run: | + set -euo pipefail + [ -f test-setup-ran ] || { echo "::error::ci/test-setup.sh did not run"; exit 1; } + [ "${FOO:-}" = "1" ] || { echo "::error::ci/test.env did not propagate FOO to the job env"; exit 1; } + echo "::notice::test-deps runs test-setup.sh and propagates test.env" diff --git a/.github/workflows/self-release.yml b/.github/workflows/self-release.yml new file mode 100644 index 0000000..7d6db93 --- /dev/null +++ b/.github/workflows/self-release.yml @@ -0,0 +1,34 @@ +# Self-CI Release +name: Self-CI Release + +# Stable release tags only. `!v*` keeps the rolling tag this workflow pushes +# from re-triggering it; pre-release tags are filtered by the step guard below. +on: + push: + tags: + - '*' + - '!v*' + +permissions: + contents: read + +jobs: + rolling-tag: + runs-on: ubuntu-latest + permissions: + contents: write # force-push the rolling major tag + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Move rolling major tag + shell: bash + run: | + tag="${GITHUB_REF_NAME}" + if [[ ! "${tag}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::notice::Tag '${tag}' is not a stable release (X.Y.Z) — rolling tag unchanged" + exit 0 + fi + rolling="v${tag%%.*}" + git tag -f "${rolling}" "${GITHUB_SHA}" + git push -f origin "${rolling}" + echo "::notice::Moved ${rolling} → ${GITHUB_SHA} (${tag})" diff --git a/.github/workflows/self-security.yml b/.github/workflows/self-security.yml index b2d1338..c0ca488 100644 --- a/.github/workflows/self-security.yml +++ b/.github/workflows/self-security.yml @@ -1,6 +1,8 @@ # Self-CI Security name: Self-CI Security +# Local `./` refs so a PR self-tests its own composite changes — the @v0-pinned +# security.yml can't (a reusable workflow's `./` resolves to the caller's checkout). on: push: branches: [main] @@ -10,5 +12,16 @@ permissions: contents: read jobs: - security: - uses: ./.github/workflows/security.yml + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/security/gitleaks + + osv-scanner: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/security/osv-scanner diff --git a/.github/workflows/self.yml b/.github/workflows/self.yml index 9e36986..5cde8f8 100644 --- a/.github/workflows/self.yml +++ b/.github/workflows/self.yml @@ -60,5 +60,6 @@ jobs: env: SHELLCHECK_OPTS: --severity=info with: + version: "v0.11.0" severity: info scandir: ./.github diff --git a/CHANGELOG.md b/CHANGELOG.md index 330cfff..90aa943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## v0.2.0 - 06/06/2026 + +### Features +- `rust-packages` — bundled Cargo pipeline: `preflight` (fmt / clippy / test on Linux, macOS, Windows), `security-gate`, branch-time `package`, tag-driven `publish` to crates.io, and the advisory `security` scan. Publish uses OIDC Trusted Publishing (`CARGO_REGISTRY_TOKEN` bootstraps a new crate) and re-runs fmt / clippy / test + `cargo publish`'s verify build on the tagged commit. +- `rust-packages` — opt-in binary distribution via cargo-dist `0.32.0`, gated on `[package.metadata.dist]`: prebuilt per-target archives, shell / powershell installers, a Homebrew formula in the declared `tap`, and an npm shim, attached to the GitHub Release (draft → undraft). Library crates self-skip. Optional `HOMEBREW_TAP_TOKEN` / `NPM_PACKAGE_REGISTRY_TOKEN` activate the tap and npm publishes. +- `rust-packages` — host native/C++ CLIs: the `dist-build` matrix exports `CARGO_DIST_TARGET` to the consumer's `ci/setup.sh`, which provisions the cross-toolchain per target. Host preflight runs unchanged. +- `rust-packages` — branch-time `package` job (`cargo package --locked`) verify-builds the packaged crate, so a dropped compile-time asset (`include_str!` / `build.rs` input) fails the PR, not the tagged publish. +- `security-gate` — blocking pre-release gate in its own reusable workflow that each `publish` `needs:`: `supply-chain` (cargo-deny for Rust, osv-scanner otherwise) + `secret-scan` (gitleaks). A vulnerability or leaked secret blocks the release through the job graph, unbypassable. Parity with the GitLab `security-gate` stage; the advisory `security.yml` reports in parallel. +- `rust/base`, `rust/native-deps`, `rust/test-deps` — composites. `rust/base` installs the toolchain, caches cargo + target, runs the optional `ci/setup.sh` and `ci/test-setup.sh` hooks (`ci/test.env` → job env), lints, and tests — so fixture-gated tests fail loud instead of skipping. +- `javascript/base` — wrap `pnpm install` in Socket Firewall (`sfw`), fail-closed, blocking confirmed-malicious packages before download. +- `self-release` — move the rolling `v0` tag to each stable release, so `@v0` tracks the latest without a manual push. + +### Fixes +- `rust-packages` — gate `publish` on `dist-build` so a failed binary build never publishes; a library crate (no `dist-build`) still does. Serialize a repo's releases with a `concurrency` group, and rebase-retry the Homebrew tap push. +- `rust-packages` — pin the `dist-*` checkouts to the tag commit, not the moving `main`; `verify-tag` stays on `publish`, the only job pushing back to `main`. +- `rust-packages` — `dist-plan` reads `[package.metadata.dist]` or `[workspace.metadata.dist]`, so a cargo-dist 0.32 workspace layout isn't misread as a library crate. +- `rust-packages` — guard `cargo publish` with a dirty-file allowlist (`Cargo.toml` / `Cargo.lock` / `CHANGELOG.md`); an unexpected dirty file fails the release instead of shipping under `--allow-dirty`. +- `rust-packages` — `--provenance` on both npm-shim publish paths. +- `rust/base` — install the `rust-toolchain.toml` channel explicitly with `rustfmt` + `clippy`. +- `javascript-npm-packages` — gate `publish` on `security-gate` (osv-scanner + gitleaks); a vulnerability or leaked secret now blocks the npm release. +- `javascript-npm-packages` — add the per-ref `concurrency` group, so two tags can't race the commit-back to `main`. +- `release` — drop the "move rolling major tag" step from publish; reusable workflows run in the caller's context, so it force-pushed a meaningless `vN` into every consumer. `v0` now moves via `self-release`. + +### Refactor +- `security` — split the blocking scans into `security-gate.yml` and keep `security.yml` advisory, so the gate scans run once (`publish` `needs:` one `security-gate` job). Supply-chain auto-routes by ecosystem; `licenses` moves to advisory (`continue-on-error`) — compliance, not a release blocker — matching GitLab. +- `security` — extract gitleaks, osv-scanner, and cargo-deny into `security/*` composites, reused by both workflows and self-CI. osv scans only when a supported manifest exists; `security/rust/cargo-deny` imposes the canonical `deny.toml` via `--config` (consumer `deny.toml` ignored, `deny.exceptions.toml` rejected) and takes a `checks` input (`advisories bans sources` for the gate, `licenses` for the advisory layer). +- `release/*`, `rust/pin-version` — extract the commit-back, tag-verify, and version-pin blocks duplicated across the tag-time jobs into composites. +- `rust/install-dist` — composite installing cargo-dist's `dist` from the prebuilt, SHA-256-verified tarball (Linux/macOS/Windows), replacing a multi-minute from-source `cargo install`. +- `rust/pin-version` — install only the `cargo-set-version` binary, not all of cargo-edit (~75% less build). +- `security/rust/cargo-deny` — drop the redundant `--deny unmaintained --deny unsound` flags; the imposed `deny.toml` already errors on both. + +### Performance +- `rust/base`, `dist-build` — replace the hand-rolled cache with `Swatinem/rust-cache` (`v2.9.1`): keeps `-sys` / CMake dirs, forces `CARGO_INCREMENTAL=0`. `dist-build` keys per target triple; `rust/base` writes only from the default branch. + +### Tests +- `self-actions` — new self-CI exercising the composites on every PR via local refs: `release/verify-tag`, `release/generate-changelog`, `release/commit-artifacts`, `security/rust/cargo-deny` override-reject, `rust/install-dist` on three OSes, and the `rust/native-deps` / `rust/test-deps` hooks. Tag-driven paths reading the runner's `GITHUB_REF_NAME` / `GITHUB_SHA` stay validated at real release time. + +### Documentation +- `README`, `CLAUDE.md` — document the native-CLI contract (`ci/setup.sh` target-aware, `ci/test.env`, `ci/test-setup.sh`), the `deny.toml` advisory escape-hatch, the Rust and npm supply-chain controls, publish auth, and the binary-distribution consumer contract (cargo-dist 0.32 workspace keys + `allow-dirty = ["ci"]`, tap/npm secrets). +- `SECURITY.md` — add the security policy (vulnerability reporting, 30-day disclosure default). + +### Configuration +- `package.json` — bump to `0.2.0`. +- `renovate.json` + `renovate.yml` — self-hosted Renovate auto-bumps the version-pinned tooling via review-gated PRs; a `postUpgradeTask` re-syncs each tarball SHA-256 in the same PR so version and checksum never drift. + ## v0.1.14 - 01/06/2026 ### Fixes diff --git a/CLAUDE.md b/CLAUDE.md index 29b708f..1897664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,15 @@ Reusable GitHub Actions workflows + composite actions for the Coroboros stack. ## Important files -- `.github/workflows/javascript-npm-packages.yml` — bundled NPM pipeline (`preflight` / `publish` / `security`). -- `.github/workflows/security.yml` — `gitleaks` + `dependency-review` + `osv-scanner`. -- `.github/actions/{check-docs,javascript/base,release/generate-changelog,release/github-release}/action.yml` — composites. -- `.github/dependabot.yml` — auto-PRs for pinned actions. +- `.github/workflows/javascript-npm-packages.yml` — bundled NPM pipeline (`preflight` / `security-gate` / `publish` / `security`). +- `.github/workflows/rust-packages.yml` — bundled Cargo pipeline (`preflight` matrix / `security-gate` / `package` / `publish` / `security`) + opt-in cargo-dist binary layer (`dist-plan` / `dist-build` / `dist-host` / `dist-publish`, gated on `[package.metadata.dist]` or `[workspace.metadata.dist]`). +- `.github/workflows/security-gate.yml` — blocking gate `publish` `needs:`. `supply-chain` (auto-routed: `Cargo.toml` → `security/rust/cargo-deny` advisories+bans+sources, else `security/osv-scanner`) + `secret-scan` (gitleaks). A separate reusable workflow so the caller's `publish` can `needs:` the whole gate as one job, running each scan once. Imposed via the package workflows, importable standalone by a non-package repo. +- `.github/workflows/security.yml` — advisory layer, never blocks: `dependency-review` (PR-only) + `licenses` (Rust, `continue-on-error`, `security/rust/cargo-deny` `checks: licenses`). License/quality policy lives here, off the gate. +- `.github/workflows/{self,self-security,self-release,self-actions}.yml` — self-CI: lint, gitleaks + osv (composites via local `./`), the `v0` rolling-tag move, and `self-actions` smoke-testing the composites against the real checkout on every PR. +- `.github/actions/{check-docs,javascript/base,rust/{base,native-deps,test-deps,install-dist,pin-version},security/{gitleaks,osv-scanner,rust/cargo-deny},release/{verify-tag,generate-changelog,github-release,commit-artifacts}}/action.yml` — composites. +- `.github/dependabot.yml` — auto-PRs for pinned action SHAs. `renovate.json` + `.github/workflows/renovate.yml` — self-hosted Renovate (needs the `RENOVATE_TOKEN` PAT secret, scope `repo` + `workflow`) auto-bumps the version-pinned tooling; `.github/renovate/sync-tool-sha.sh` re-syncs each paired tarball SHA-256 in the same PR. - `security/.gitleaks.toml` — canonical gitleaks ruleset. +- `security/deny.toml` — canonical cargo-deny ruleset, imposed via `--config` (consumer `deny.toml` ignored; `deny.exceptions.toml` rejected). An unfixable transitive advisory → PR a justified `ignore = ["RUSTSEC-…"]` (with `# why`) to this file, never a per-repo override. - `README.md` — public documentation (single source for pipelines, composables, structure, flow, env, security, examples). ## Rules @@ -21,7 +25,7 @@ Reusable GitHub Actions workflows + composite actions for the Coroboros stack. - **Imposed, not proposed.** Zero `inputs:` / `secrets:` on reusable workflows unless variation is legitimate. - **Pin third-party actions by commit SHA**, inline `# vX` comment. No `@main`, `@master`, `@vX`. - **Pin tooling binaries by version.** SHA-256 verification on binary release tarballs. No `curl | bash`. -- **Composite refs in this repo**: `coroboros/ci/.github/actions/@v0`. +- **Composite refs**: `coroboros/ci/.github/actions/@v0` from reusable workflows and consumers. Exception — `self-security.yml` uses local `./.github/actions/security/` so a PR self-tests its own composites; a reusable workflow's `./` resolves to the caller's checkout, so `security.yml` must pin `@v0`. - **`secrets:`** declares only what the job consumes. Never `secrets: inherit`. - **`gitleaks` CLI direct**, not `gitleaks/gitleaks-action@v2` (paid org license). - **House style**: @@ -40,6 +44,5 @@ Reusable GitHub Actions workflows + composite actions for the Coroboros stack. - PR-only; no direct commits to `main`. - In the PR (before merge): bump `package.json:version` + prepend `CHANGELOG.md` section (`## vX.Y.Z - DD/MM/YYYY`). - Squash-merge. -- `git tag X.Y.Z && git push origin X.Y.Z` (no `v` prefix). -- `git tag -f v0 && git push -f origin v0` (rolling major). +- `git tag X.Y.Z && git push origin X.Y.Z` (no `v` prefix). `self-release.yml` then moves the rolling `v0` tag — no manual `git tag -f v0`. - `gh release create X.Y.Z --title X.Y.Z --notes-file `. diff --git a/README.md b/README.md index 81882e3..5b3ad95 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Drop into any `@coroboros/*` repo via `uses: coroboros/ci/.github/workflows/ -**Imposed, not proposed.** Pipelines expose zero `inputs:` — same install flags, same publish auth, same security baseline across every Coroboros repo. Consumers wire it in. +**Imposed, not proposed.** Pipelines expose zero `inputs:`. Every Coroboros repo inherits identical install flags, publish auth, and security gates. Consumers wire it in. - [Pipelines](#pipelines) - [Composable actions](#composable-actions) @@ -58,24 +58,34 @@ Consumer requirements: +
+security-gate + +
+ +**Trigger**: `every push`. Gates `publish` via `needs:` — a release waits on it. + +Calls [`security-gate.yml`](#security-gateyml): `osv-scanner` (`pnpm-lock.yaml` vs [OSV.dev](https://osv.dev/)) + `gitleaks` (full history). `javascript/base` already gates the install (Socket Firewall + `--frozen-lockfile`); the gate adds the known-CVE and leaked-secret blocks so a vulnerable dependency or a leaked secret stops the release through the job graph, not per-repo branch protection. See [Security](#security). + +
+
publish
-**Trigger**: `tag push` +**Trigger**: `tag push`. Gated by `security-gate` (`needs:`) — osv-scanner and gitleaks must pass first. **Sequence**: 1. Checkout `main` with full history -2. Verify `main` HEAD matches the tag SHA +2. Verify `main` HEAD matches the tag SHA via [`release/verify-tag`](#composable-actions) 3. Run [`check-docs`](#composable-actions) 4. Run [`javascript/base`](#composable-actions) 5. Pin `package.json` version to the tag 6. Generate `CHANGELOG.md` section via [`release/generate-changelog`](#composable-actions) 7. Publish to npm — OIDC + provenance or token-based via `.npmrc` (see [Security](#security)) 8. Create GitHub Release via [`release/github-release`](#composable-actions) -9. Commit release artifacts back to `main` as `chore: release ${tag}` -10. Move rolling major tag `vN` to the release commit (skipped on pre-release tags) +9. Commit release artifacts back to `main` via [`release/commit-artifacts`](#composable-actions)
@@ -86,23 +96,127 @@ Consumer requirements: **Trigger**: `every call` -Calls `security.yml` — see [Security](#security). +Calls the advisory [`security.yml`](#securityyml) — `dependency-review`, reporting only, never blocks the release. See [Security](#security). + + + +### `rust-packages.yml` + +Bundled Cargo CI. Tag-driven release, same as the npm pipeline. + +Consumer requirements: +- `rust-toolchain.toml` — pins the channel. `rust/base` installs that channel explicitly with the `rustfmt` + `clippy` components (no reliance on lazy auto-resolution on first `cargo` use). The pipeline assumes `rustup` is on `PATH` (every GitHub-hosted runner ships it); a custom container image pinned for a `dist-build` target must provide it. +- `Cargo.toml` and a committed `Cargo.lock` — `clippy` and `test` run `--locked`. +- compile-time assets — any `include_str!` / `include_bytes!` / `build.rs` input must sit under the package root and stay unignored (no `exclude`/`.gitignore` rule drops it). The `package` job verify-builds the packaged crate so a dropped asset fails the PR, not the tagged publish. +- cargo-deny policy — imposed by `coroboros/ci`; no consumer `deny.toml` required, and a local one is ignored. See [Security](#security). +- `ci/setup.sh` — optional native build-dependency hook. Receives `RUNNER_OS`, `RUNNER_ARCH`, and `CARGO_DIST_TARGET` (space-separated target triples on a `dist-build` cross leg; empty on host preflight). Installs `-sys` / CMake toolchains and exports env via `$GITHUB_ENV`. No-op when absent. +- `ci/test.env` — optional. `KEY=value` lines loaded into the job environment before `cargo test`, so model/fixture-gated tests fail loud instead of skipping. No-op when absent. +- `ci/test-setup.sh` — optional. Runs before `cargo test` to stage test fixtures (prefetch a model, install a runtime tool). No-op when absent. +- crates.io publishing — configure [OIDC Trusted Publishing](#security), or set `CARGO_REGISTRY_TOKEN` to bootstrap the first publish of a new crate. Tagged builds always publish. +- binary distribution — optional. Declare `[package.metadata.dist]` in `Cargo.toml` (cargo-dist `0.32.0`) to attach prebuilt binaries and installers to the release; drop `release-plz`. cargo-dist `0.32` reads its workspace-global keys (`cargo-dist-version`, `ci`, `publish-jobs`) from `[workspace.metadata.dist]` only — a single-crate repo adds an empty `[workspace]` — and needs `allow-dirty = ["ci"]` there so `dist plan` doesn't claim its own workflow (this pipeline owns it). Absent → source-only (crates.io), unchanged. + +
+preflight + +
+ +**Trigger**: `branch push`. **Matrix**: `ubuntu-latest`, `macos-latest`, `windows-latest`. + +**Sequence**: +1. Checkout +2. Run [`check-docs`](#composable-actions) +3. Run [`rust/base`](#composable-actions) + +
+ +
+security-gate + +
+ +**Trigger**: `every push`. Gates `publish` via `needs:` — a release waits on it. + +Calls [`security-gate.yml`](#security-gateyml): `cargo-deny` (advisories + bans + sources) + `gitleaks` (full history). Running on every push re-checks a tagged release against the latest advisory DB before it ships, not only at PR time. License policy is not here — it runs advisory in `security`. See [Security](#security). + +
+ +
+package + +
+ +**Trigger**: `branch push`. + +`cargo package --locked` builds the crate from its packaged tarball — the same bytes `cargo install` would compile downstream — after [`rust/native-deps`](#composable-actions) supplies any `-sys` toolchain. A compile-time asset (`include_str!` / `include_bytes!` / `build.rs` input) silently dropped from the package fails the PR here. `publish`'s own `cargo publish` verify build is the tag-time twin; `preflight` builds from the work tree and would not catch it. + +
+ +
+publish + +
+ +**Trigger**: `tag push`. Gated by `security-gate` (`needs:`) — cargo-deny and gitleaks must pass first — and skipped if `dist-build` fails, so a broken binary build never produces a crates.io publish. + +**Sequence**: +1. Checkout `main` with full history +2. Verify `main` HEAD matches the tag SHA via [`release/verify-tag`](#composable-actions) +3. Run [`check-docs`](#composable-actions) +4. Run [`rust/base`](#composable-actions) +5. Pin `Cargo.toml` to the tag via [`rust/pin-version`](#composable-actions) +6. Generate `CHANGELOG.md` section via [`release/generate-changelog`](#composable-actions) +7. `cargo publish` to crates.io — OIDC by default, token bootstrap for a new crate (see [Security](#security)) +8. Create GitHub Release via [`release/github-release`](#composable-actions) — a draft when the consumer ships binaries (undrafted once assets upload), else final +9. Commit `Cargo.toml`, `Cargo.lock`, `CHANGELOG.md` back to `main` via [`release/commit-artifacts`](#composable-actions) + +
+ +
+binary distribution (opt-in) + +
+ +**Trigger**: `tag push`, only when `Cargo.toml` declares `[package.metadata.dist]` (cargo-dist `0.32.0`). Library crates skip every job below with zero config; the release stays non-draft as above. + +The shared pipeline is the sole release authority — `publish` creates the one GitHub Release (a draft for binary repos), and these jobs attach artifacts to it. cargo-dist (`dist`) only builds, never owns the release. + +- **`dist-plan`** — detects the metadata, pins the version, runs `dist plan` to compute the per-target build matrix. +- **`dist-build`** — matrix over the declared `targets`, gated by `security-gate` (`needs:`); caches `~/.cargo` + `target/` per target via `rust-cache`; builds each prebuilt archive (`dist build --artifacts=local`). Exports `CARGO_DIST_TARGET` so the consumer's `ci/setup.sh` provisions the cross-toolchain. +- **`dist-host`** — builds the global installers + Homebrew formula + npm shim (`dist build --artifacts=global`; final download URLs derive from repo + tag), uploads every asset to the release, then undrafts it. +- **`dist-publish`** — commits the formula to the declared `tap` (`HOMEBREW_TAP_TOKEN`, rebase-retried so concurrent releases don't clobber the shared tap) and publishes the npm shim with provenance (token bootstrap or OIDC, attested via the job's `id-token` either way). Each self-skips when its installer or secret is absent. + +`dist` is installed prebuilt and SHA-256 verified (version `0.32.0`) via [`rust/install-dist`](#composable-actions). Per-target Cargo features are not expressible in cargo-dist 0.32.0. Set them consumer-side via `cfg`, e.g. a Metal build gated on `cfg(target_os = "macos")`. The shared dist binaries are CPU-only; a consumer MAY keep a supplemental per-package workflow for GPU/accelerated builds (e.g. a Metal smoke). macOS Developer-ID signing + notarization are deferred. + +
+ +
+security + +
+ +**Trigger**: `every call`. Calls the advisory [`security.yml`](#securityyml) — `dependency-review` + `licenses`, reporting only, never blocks the release.
+### `security-gate.yml` + +The blocking gate, split from the advisory layer so it can be owned as a black box. Two parallel jobs, both fail the release through the caller's `needs:` graph — a dev can't bypass them: + +- **`supply-chain`** — auto-routed by ecosystem: a `Cargo.toml` repo runs [`security/rust/cargo-deny`](#composable-actions) (advisories + bans + sources); any other runs [`security/osv-scanner`](#composable-actions). One tool per repo, never both, so a crate isn't vuln-scanned twice. A repo with no supported manifest skips (osv's no-manifest path). +- **`secret-scan`** — [`security/gitleaks`](#composable-actions), full git history, canonical ruleset. + +Imposed on every package pipeline (a `security-gate` job `needs:`-ed by `publish`) and importable directly by a non-package repo. Holds only what *blocks*: a compromised dependency or a leaked secret. License and quality policy live in `security.yml`. + ### `security.yml` -Reusable sub-workflow with three parallel scans: +The advisory layer — reports, never blocks (parity with GitLab's `allow_failure: true`): -- **`gitleaks`** — Installs `v8.30.1` (SHA-256 verified), scans git history with the [`security/.gitleaks.toml`](security/.gitleaks.toml) ruleset, fails on detected leaks. Emits SARIF as the `gitleaks-report` artifact (30-day retention). - **`dependency-review`** — PR-only; needs repo's **Dependency graph** enabled. Fails on high-severity CVE introduced by the dep diff. Uses `actions/dependency-review-action@v4`. -- **`osv-scanner`** — Scans lockfiles recursively against [OSV.dev](https://osv.dev/) via `google/osv-scanner-action@v2`. Fails on any known vulnerability. - -Imposed on every Coroboros workflow. Standalone wire-up — see [Examples](#examples). +- **`licenses`** — Rust-only (`continue-on-error`): [`security/rust/cargo-deny`](#composable-actions) `checks: licenses` against the canonical allow-list. A non-allowed license is surfaced, never blocks the release. Skips a repo with no `Cargo.toml`. --- -**Notes** — pin via `@v0` (rolling major, auto-bumped on each release) or `@x.y.z` (immutable). Pipelines don't chain via `needs:`; the only sub-workflow call is `security` → `security.yml`. +**Notes** — pin via `@v0` (rolling major) or `@x.y.z`. `self-release.yml` moves `v0` to each stable release, so `@v0` always tracks the latest. `@x.y.z` pins the workflow file. The composite actions it calls are hardcoded `@v0`, so `@x.y.z` is not a full freeze — the nested actions still follow `v0`. Each `publish` `needs:` the `security-gate` job, so the release is re-checked (cargo-deny or osv-scanner, plus gitleaks) before it ships. The `security` job scans in parallel for reporting — it does not gate `publish` (see [Security](#security)). --- @@ -112,8 +226,18 @@ Imposed on every Coroboros workflow. Standalone wire-up — see [Examples](#exam | :--- | :--- | :--- | | `check-docs` | transverse | Context dump + documentation check. | | `javascript/base` | JavaScript | Sets up Node + corepack pnpm, caches the store, writes `.npmrc` from env, then installs, lints, builds (when present), tests. | +| `rust/base` | Rust | Installs the `rust-toolchain.toml` channel (`rustfmt` + `clippy`), caches `~/.cargo` + `target/` via `rust-cache`, runs [`rust/native-deps`](#composable-actions), then `cargo fmt --check`, `clippy -D warnings`, then [`rust/test-deps`](#composable-actions) and `test`. | +| `rust/native-deps` | Rust | Runs the optional `ci/setup.sh` native build-dependency hook (sees `CARGO_DIST_TARGET` on a `dist-build` cross leg). Shared by `rust/base` and the `dist-build` matrix. No-op when absent. | +| `rust/test-deps` | Rust | Loads the optional `ci/test.env` into the job env and runs the optional `ci/test-setup.sh` fixture hook before `cargo test`. Used by `rust/base`. No-op when absent. | +| `rust/install-dist` | Rust | Installs cargo-dist's `dist` binary, prebuilt and SHA-256 verified (Linux/macOS/Windows). Shared by the `dist-plan`, `dist-build`, `dist-host` jobs. | +| `rust/pin-version` | Rust | Installs version-pinned `cargo-set-version` (cargo-edit) and stamps `Cargo.toml` to the release tag. Shared by `publish` and the `dist-*` jobs. | +| `security/gitleaks` | transverse | Installs gitleaks (SHA-256 verified), scans with the canonical ruleset, emits SARIF. Behind `security-gate.yml`'s `secret-scan` and self-CI. | +| `security/osv-scanner` | transverse | Scans dependency manifests for known vulnerabilities (OSV.dev); skips a repo with no supported manifest. Behind `security-gate.yml`'s `supply-chain` (non-Rust) and self-CI. | +| `security/rust/cargo-deny` | Rust | Runs cargo-deny against the canonical imposed `security/deny.toml` (sparse-checked from `coroboros/ci`, no consumer override). The `checks` input selects which checks run — `advisories bans sources` for the `security-gate.yml` supply-chain, `licenses` for the `security.yml` advisory layer. | +| `release/verify-tag` | transverse | Fails the release unless the checked-out `main` HEAD matches the tag SHA. Shared by the npm and Rust `publish` jobs — the tag-time jobs that check out `main` to push back; the `dist-*` jobs pin to the tag commit (`github.sha`) instead. | | `release/generate-changelog` | transverse | SemVer-strict tag guard + generates or reuses the `## vX.Y.Z` section in `CHANGELOG.md` from Conventional Commits. Outputs `body`. Idempotent. | -| `release/github-release` | transverse | Creates the GitHub Release for the current tag. Body typically chained from `release/generate-changelog` (see [Examples](#examples)). | +| `release/github-release` | transverse | Creates the GitHub Release for the current tag, optionally as a `draft`. Body typically chained from `release/generate-changelog` (see [Examples](#examples)). | +| `release/commit-artifacts` | transverse | Stages the given files and commits them back to `main` as `chore: release ${tag} [skip ci]`. No-op when nothing changed. | --- @@ -162,7 +286,7 @@ Section format: `## vX.Y.Z - DD/MM/YYYY`. Idempotent. Reuses an existing hand-cu ## Environment -Zero inputs on pipelines and on every composite — imposed, not proposed. Configuration flows through the caller's `secrets:` block. Every npm-publish-related value is a **secret** (encrypted at rest, masked in logs); none of them are GitHub `vars`. +Zero `inputs:` — configuration flows through the caller's `secrets:` block. Every npm-publish-related value is a **secret** (encrypted at rest, masked in logs); none are GitHub `vars`.
Secrets (caller's secrets: block) @@ -180,16 +304,26 @@ Zero inputs on pipelines and on every composite — imposed, not proposed. Confi
-javascript/base env contract (standalone composition) +Secrets — rust-packages.yml
-| env | required | description | -| :-- | :---: | :--- | -| `NPM_CONFIG_FILE` | ✔ — fail if missing | `.npmrc` content | -| `NPM_EXTRA_CONFIG` | | Appended after `NPM_CONFIG_FILE` | +All optional. A consumer that wires none still gets crates.io plus prebuilt archives and installers on the release; Homebrew and npm activate only when their secret (or OIDC) is configured. + +| name | required | description | +| :--- | :---: | :--- | +| `CARGO_REGISTRY_TOKEN` | | crates.io token. Bootstraps the first publish of a new crate; absent → OIDC Trusted Publishing. | +| `HOMEBREW_TAP_TOKEN` | | Push access to the Homebrew tap repo named by `tap` in `[package.metadata.dist]`. Absent → the formula publish self-skips. | +| `NPM_PACKAGE_REGISTRY_TOKEN` | | npm token bootstrapping the first publish of the binary npm shim; absent → OIDC Trusted Publisher. The shim publishes with provenance either way. | + +
+ +
+javascript/base env contract (standalone composition) + +
-Set both at the caller's workflow- or job-level `env:`. +A composite reads `env`, not `secrets:`. Composing `javascript/base` outside the bundled pipeline, set the same `NPM_CONFIG_FILE` (required, fails if missing) and `NPM_EXTRA_CONFIG` (optional) from the Secrets table above at the caller's workflow- or job-level `env:`.
@@ -219,16 +353,54 @@ Caller job needs `permissions: contents: write`. Uses `${{ github.token }}` inte ## Security
-Supply chain — pnpm install flags +Supply chain — npm + +
+ +The target: a hijacked maintainer, a typosquat, a `postinstall` payload, or a fresh bad version pulled before it is caught. `javascript/base` enforces four layers — the runner equivalent of the GitLab pipeline's image-baked hardening: + +| Layer | Mechanism | Where | +| :--- | :--- | :--- | +| Cooldown | versions under 7 days old are quarantined; `@coroboros/*` excluded so internal publishes flow immediately | consumer `pnpm-workspace.yaml` | +| Firewall | Socket Firewall (`sfw`) proxies the fetch, blocks confirmed-malicious packages before download | `javascript/base` | +| No install scripts | `--ignore-scripts` blocks `postinstall` code execution | `javascript/base` + `.npmrc` | +| Frozen lockfile | `--frozen-lockfile` rejects a stale or tampered `pnpm-lock.yaml` | `javascript/base` | + +Cooldown is consumer config — pnpm 11 reads `pnpm-workspace.yaml` (`minimum-release-age` in `.npmrc` on pnpm 10.x): + +```yaml +# pnpm-workspace.yaml +minimumReleaseAge: 10080 # 7 days, in minutes +minimumReleaseAgeExclude: + - '@coroboros/*' # internal packages install immediately +``` + +**Honest gaps.** `sfw` is fail-closed — if it can't install or run, the job fails rather than fetch unprotected. It inspects public-registry fetches out of the box; packages pulled through a private proxy pass uninspected, held instead by the cooldown. pnpm itself is corepack-resolved from `packageManager`, so no floating version reaches the runner. + +
+ +
+Supply chain — Rust (rust-packages.yml)
-`pnpm install --frozen-lockfile --ignore-scripts` runs inside `javascript/base`. +GitHub-hosted runners share no hardened base image, so the Rust pipeline enforces its supply-chain controls in the workflow: + +| Risk | Rust control | +| :--- | :--- | +| Untrusted source, typosquat | `cargo-deny` sources — crates.io only; git and alternative registries denied | +| Lock drift, tampered dependencies | committed `Cargo.lock` + `--locked` on `clippy` and `test` — fails on a stale or altered lock | +| Known vulnerability | `cargo-deny` advisories — RustSec vulnerabilities, unmaintained, unsound, yanked | +| License drift | `cargo-deny` licenses — allow-list, **advisory** (reports, never blocks) | +| Banned or wildcard dependency | `cargo-deny` bans | + +`cargo-deny`'s blocking checks (`advisories`, `bans`, `sources`) run in [`security-gate.yml`](#security-gateyml)'s `supply-chain` on every push and gate `publish` (`needs:`), so a tagged release is re-checked against the latest advisory DB before it ships. `licenses` runs advisory in `security.yml` — reported, never blocking, since licenses are compliance rather than a supply-chain risk. The controls above are **imposed**: cargo-deny applies the canonical [`security/deny.toml`](security/deny.toml) via `--config`, sparse-checked from `coroboros/ci` — the [`gitleaks`](#composable-actions) model. A consumer `deny.toml` is ignored; a `deny.exceptions.toml` fails the job. Exceptions are changed centrally in `coroboros/ci`, never per repo — an unfixable transitive advisory is suppressed by a PR adding a justified `ignore = ["RUSTSEC-…"]` (with a `# why` comment) to the canonical `deny.toml`. -- `--frozen-lockfile` — fails on stale or tampered `pnpm-lock.yaml`. Gate against transitive-dependency injection. -- `--ignore-scripts` — skips lifecycle scripts (`preinstall`, `install`, `postinstall`) of every dependency. Cuts the postinstall supply-chain vector. +**Publish auth.** crates.io publish uses OIDC Trusted Publishing by default — `rust-lang/crates-io-auth-action` mints a short-lived token per run, no long-lived secret in the repo. `CARGO_REGISTRY_TOKEN` is needed only to bootstrap the first publish of a new crate (Trusted Publishing binds to an existing crate); configure Trusted Publishing on crates.io afterwards and drop the token. The verify build runs on publish (no `--no-verify`). It compiles the packaged tarball standalone, catching a crate that only builds in-workspace before the immutable release lands. -pnpm CLI resolved via corepack from `packageManager`. No floating version reaches the runner. +Two residual risks have no clean CI control. Both are documented here: +- **Build scripts run.** `cargo` has no `--ignore-scripts`; `build.rs` and proc-macros execute at build time. `--locked`, `cargo-deny` bans, and dependency review reduce the exposure; they do not remove it. +- **No publish cooldown.** crates.io has no `minimumReleaseAge`, so a freshly hijacked version is held off by the committed lock and `cargo-deny` advisories rather than a time delay.
@@ -255,8 +427,8 @@ prefer-online=true | `@coroboros:registry=https:${NPM_PACKAGE_REGISTRY}` | Scope-resolved registry — `${NPM_PACKAGE_REGISTRY}` expands from the same-named secret. | | `save-exact=true` | Pin exact versions on `add` / `install`. | | `fund=false` | Suppress funding noise in CI logs. | -| `audit=false` | `osv-scanner` (in `security.yml`) covers vulnerability scans natively. | -| `ignore-scripts=true` | Belt-and-suspenders against postinstall supply-chain attacks — backs up the `--ignore-scripts` flag already passed by `javascript/base` on every `pnpm install`. | +| `audit=false` | `osv-scanner` (in `security-gate.yml`) covers vulnerability scans natively. | +| `ignore-scripts=true` | Defense in depth against postinstall supply-chain attacks — backs up the `--ignore-scripts` flag already passed by `javascript/base` on every `pnpm install`. | | `package-lock=false` | Prevent `npm` from emitting a parasitic `package-lock.json` in pnpm repos. | | `lockfile=true` | Explicit `pnpm-lock.yaml` enablement. Required on pnpm `< 11.0.0` consumers, where the preceding `package-lock=false` is interpreted as `lockfile=false` and collides with `pnpm install --frozen-lockfile`. Pnpm `>= 11` already defaults to `true` and ignores `package-lock` for `pnpm-lock.yaml`, so the line is harmless there. | | `prefer-online=true` | Re-fetch dep metadata each install — local cache cannot mask a yanked or republished version. | @@ -319,7 +491,7 @@ Self-CI binaries pinned by version. `actionlint` and `gitleaks` install from rel Canonical ruleset at `security/.gitleaks.toml` in this repo. Stack-specific rules cover Resend, Neon Postgres, PostHog, and GitHub fine-grained PATs on top of the gitleaks defaults. -`security.yml` sparse-checks the file out of `coroboros/ci` at runtime — imposed, no consumer override. +The `security/gitleaks` composite sparse-checks the file out of `coroboros/ci` at runtime — imposed, no consumer override. @@ -360,10 +532,43 @@ jobs:
-security.yml standalone (non-npm repo) +rust-packages.yml wire-up + +
+ +```yaml +# consumer-repo/.github/workflows/ci.yml +name: CI +on: + push: + branches: [develop, main] + tags: ['*'] + pull_request: + workflow_dispatch: + +jobs: + ci: + uses: coroboros/ci/.github/workflows/rust-packages.yml@v0 + permissions: + contents: write # GitHub Release + commit-back on tag + id-token: write # crates.io + npm OIDC publish on tag + secrets: + # First publish of a new crate only — drop once Trusted Publishing is configured (see Security): + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # Binary distribution ([package.metadata.dist] in Cargo.toml) — both optional: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + NPM_PACKAGE_REGISTRY_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }} +``` + +
+ +
+security on a non-package repo
+A repo that ships no package still imports the security workflows directly — the blocking gate plus the advisory layer: + ```yaml # consumer-repo/.github/workflows/security.yml name: Security @@ -378,7 +583,9 @@ permissions: contents: read jobs: - scan: + gate: + uses: coroboros/ci/.github/workflows/security-gate.yml@v0 + advisory: uses: coroboros/ci/.github/workflows/security.yml@v0 ``` @@ -427,7 +634,7 @@ jobs: fetch-depth: 0 - id: changelog uses: coroboros/ci/.github/actions/release/generate-changelog@v0 - # ...your publish step (docker push, gh release upload, etc.)... + # ...the publish step (docker push, gh release upload, etc.)... - uses: coroboros/ci/.github/actions/release/github-release@v0 with: body: ${{ steps.changelog.outputs.body }} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a02e343 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security policy + +## Supported versions + +Latest `main` only. Tagged releases follow the same support model as `main` at the time of the release. + +## Reporting a vulnerability + +Report vulnerabilities to **ob@coroboros.com**. Do not open public issues, PRs, or comments for security problems. + +Expected initial response: within 5 business days. + +Coordinated disclosure preferred. A 30-day fix window is the default before public disclosure; a different window can be agreed when the severity demands it. diff --git a/package.json b/package.json index 69a261b..6e82b0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coroboros/ci", - "version": "0.1.13", + "version": "0.2.0", "private": true, "description": "Reusable GitHub Actions CI for the Coroboros stack.", "license": "SEE LICENSE IN LICENSE.md", diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..6b2cf7d --- /dev/null +++ b/renovate.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommits", + ":semanticCommitTypeAll(chore)", + ":dependencyDashboard", + ":separateMajorReleases" + ], + "labels": ["renovate"], + "prConcurrentLimit": 5, + "prHourlyLimit": 2, + "rangeStrategy": "bump", + "enabledManagers": ["custom.regex"], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/(^|/)\\.github/workflows/self\\.yml$/"], + "matchStrings": ["ACTIONLINT_VERSION:\\s*\"(?[^\"]+)\""], + "depNameTemplate": "rhysd/actionlint", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v?(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/(^|/)\\.github/workflows/self\\.yml$/"], + "matchStrings": ["YAMLLINT_VERSION:\\s*\"(?[^\"]+)\""], + "depNameTemplate": "yamllint", + "datasourceTemplate": "pypi" + }, + { + "customType": "regex", + "managerFilePatterns": ["/(^|/)\\.github/actions/security/gitleaks/action\\.yml$/"], + "matchStrings": ["GITLEAKS_VERSION:\\s*\"(?[^\"]+)\""], + "depNameTemplate": "gitleaks/gitleaks", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v?(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/(^|/)\\.github/actions/rust/install-dist/action\\.yml$/"], + "matchStrings": ["CARGO_DIST_VERSION:\\s*\"(?[^\"]+)\""], + "depNameTemplate": "axodotdev/cargo-dist", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v?(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/(^|/)\\.github/actions/rust/pin-version/action\\.yml$/"], + "matchStrings": ["CARGO_EDIT_VERSION:\\s*\"(?[^\"]+)\""], + "depNameTemplate": "cargo-edit", + "datasourceTemplate": "crate" + } + ], + "packageRules": [ + { + "description": "Review-gate every update; nothing automerges.", + "matchUpdateTypes": ["minor", "patch", "major", "pin", "digest"], + "automerge": false + }, + { + "description": "These tools carry a paired tarball SHA-256; a postUpgradeTask re-syncs every *_SHA256 to the bumped version so version + checksum land in the same PR. Self-hosted only — the command is allowlisted via RENOVATE_ALLOWED_COMMANDS in renovate.yml.", + "matchDepNames": ["rhysd/actionlint", "gitleaks/gitleaks", "axodotdev/cargo-dist"], + "postUpgradeTasks": { + "commands": ["bash .github/renovate/sync-tool-sha.sh"], + "fileFilters": [ + ".github/actions/security/gitleaks/action.yml", + ".github/workflows/self.yml", + ".github/actions/rust/install-dist/action.yml" + ], + "executionMode": "branch" + } + } + ] +} diff --git a/security/deny.toml b/security/deny.toml new file mode 100644 index 0000000..db5fced --- /dev/null +++ b/security/deny.toml @@ -0,0 +1,51 @@ +# Canonical Coroboros cargo-deny ruleset — imposed on every consumer. +# The security/cargo-deny composite sparse-checks this file out of coroboros/ci +# and passes it via `--config`, so a consumer's own deny.toml is ignored. Edit +# the policy here only; per-repo exceptions are not accepted (parity with gitleaks). + +[graph] +# No `targets` key on purpose: keep every platform's dependencies in scope. +# `targets` is a narrowing filter — adding it would let another OS's deps escape +# the gate, so a macOS-only malicious dep can't slip past an ubuntu-run check. +all-features = true + +[advisories] +# v2 schema: vulnerability advisories always error and cannot be downgraded; +# `ignore` is the only suppressor and is kept empty by default. unmaintained/unsound +# at "all" scope error on any matching crate (an abandoned crate is a takeover vector). +# Escape-hatch for an unfixable transitive advisory: add the ID below with a `# why` +# comment in a PR to coroboros/ci — never a per-repo override (consumer deny.toml is ignored). +yanked = "deny" +unmaintained = "all" +unsound = "all" +ignore = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] + +[bans] +wildcards = "deny" +allow-wildcard-paths = true # path/workspace + dev-deps resolve; public-crate registry wildcards still fail +multiple-versions = "warn" # duplicate versions are bloat, not an attack vector — warn, never block + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "0BSD", + "ISC", + "Zlib", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "CDLA-Permissive-2.0", + "BSL-1.0", +] +confidence-threshold = 0.8 +private = { ignore = true }