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..e1ee3bc --- /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/cargo-deny/action.yml b/.github/actions/security/cargo-deny/action.yml new file mode 100644 index 0000000..2b0e820 --- /dev/null +++ b/.github/actions/security/cargo-deny/action.yml @@ -0,0 +1,33 @@ +# Security cargo-deny Action Composite +name: security-cargo-deny +description: Run cargo-deny against the canonical Coroboros ruleset (imposed, no consumer override). + +# 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" diff --git a/.github/actions/security/gitleaks/action.yml b/.github/actions/security/gitleaks/action.yml new file mode 100644 index 0000000..c9c8ad3 --- /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/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..f1caff5 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,23 @@ jobs: - uses: coroboros/ci/.github/actions/check-docs@v0 - uses: coroboros/ci/.github/actions/javascript/base@v0 + supply-chain: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 + publish: if: ${{ github.ref_type == 'tag' }} + needs: [supply-chain, secret-scan] # no release ships until osv-scanner and gitleaks pass runs-on: ubuntu-latest permissions: contents: write # for GitHub Release creation + commit-back to main @@ -46,16 +67,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 +83,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 +96,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 +104,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..6c86990 --- /dev/null +++ b/.github/workflows/renovate.yml @@ -0,0 +1,28 @@ +# Renovate +name: Renovate + +# Self-hosted Renovate: bumps the version-pinned tooling (renovate.json custom managers) and +# re-syncs each paired tarball SHA-256 in the same PR via a postUpgradeTask. Runs only when this +# workflow fires (the cron is the schedule). Needs a PAT in the RENOVATE_TOKEN secret so its PRs +# trigger the rest of self-CI — the built-in GITHUB_TOKEN would not, and it can't touch workflow +# files. PAT scope is documented 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..8d38cf2 --- /dev/null +++ b/.github/workflows/rust-packages.yml @@ -0,0 +1,353 @@ +# 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 + + supply-chain: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: coroboros/ci/.github/actions/security/cargo-deny@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 + + 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, supply-chain, secret-scan] # no artifact builds until the gates pass + 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.supply-chain.result == 'success' && needs.secret-scan.result == 'success' && needs.dist-plan.result == 'success' && needs.dist-build.result != 'failure' }} + needs: [supply-chain, secret-scan, dist-plan, dist-build] # gates 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.yml b/.github/workflows/security.yml index 86192e1..5285eed 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -7,12 +7,6 @@ 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 @@ -20,66 +14,7 @@ jobs: - 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 + - uses: coroboros/ci/.github/actions/security/gitleaks@v0 dependency-review: if: ${{ github.event_name == 'pull_request' }} @@ -95,8 +30,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 - with: - scan-args: |- - --recursive - ./ + - uses: coroboros/ci/.github/actions/security/osv-scanner@v0 diff --git a/.github/workflows/self-actions.yml b/.github/workflows/self-actions.yml new file mode 100644 index 0000000..8909727 --- /dev/null +++ b/.github/workflows/self-actions.yml @@ -0,0 +1,204 @@ +# Self-CI Actions +name: Self-CI Actions + +# A PR self-tests its own composite logic via local `./` refs. The composites run against +# the real checkout, where GITHUB_SHA == HEAD (pull_request / push semantics). `commit-artifacts` +# rewires `origin` to a local bare remote and relies on `contents: read` as the backstop, so no +# real branch is touched. No secrets. +# +# A composite's tag-driven paths that read the runner's GITHUB_REF_NAME / GITHUB_SHA can't be +# faked here — those reserved defaults are re-injected inside a composite and can't be overridden +# by the caller — so generate-changelog's auto-gen and pin-version's stamp are exercised at real +# release time. This workflow covers the parts reachable on a non-tag event. +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/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/CHANGELOG.md b/CHANGELOG.md index 330cfff..5925f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## v0.2.0 - 06/06/2026 + +### Features +- `rust-packages` — host native/C++ CLIs without the shared pipeline learning zig, clang, or musl: the `dist-build` matrix exports `CARGO_DIST_TARGET` (the resolved target triple) to `rust/native-deps`'s `ci/setup.sh`, so a consumer provisions the cross-toolchain per target. Host preflight sees an empty target and runs unchanged; the shared pipeline installs no cross-toolchain itself. +- `rust/test-deps` — composite loading the optional `ci/test.env` (`KEY=value` lines → job environment) and running the optional `ci/test-setup.sh` fixture hook before `cargo test`, so model/ffmpeg-gated tests fail loud instead of silently skipping. `rust/base` runs it; a pure-Rust consumer without the files is unaffected. +- `rust-packages` — bundled Cargo pipeline: `preflight` (`cargo fmt --check` / `clippy -D warnings` / `test` on a Linux, macOS, Windows matrix), `supply-chain` (`cargo-deny`), tag-driven `publish` to crates.io, and the shared `security` scan. `supply-chain` runs on every push and gates `publish` (`needs:`), so cargo-deny re-checks the release against the latest advisory DB before it ships rather than scanning in parallel. Publish authenticates with crates.io OIDC Trusted Publishing by default (`rust-lang/crates-io-auth-action`); `CARGO_REGISTRY_TOKEN` is the first-publish bootstrap for a new crate. `publish` re-runs `rust/base` (fmt / clippy / test) on the tagged commit before `cargo publish`, mirroring the npm pipeline, so a library crate is re-validated at tag time and not only at PR time; `cargo publish`'s own verify build then runs with no `--no-verify`, so a crate that only builds in-workspace fails before an immutable release. +- `rust-packages` — add a branch-time `package` job (`cargo package --locked`, after `rust/native-deps`) that verify-builds the crate from its packaged tarball. A compile-time asset — an `include_str!`/`include_bytes!` file or a `build.rs` input — dropped from the package by an `exclude`/`.gitignore` rule now fails the PR rather than the tagged `cargo publish`, which verify-builds the same tarball at release time. +- `rust-packages` — opt-in binary-distribution layer via cargo-dist (`dist` `0.32.0`), gated on `[package.metadata.dist]`. A tagged binary crate gets prebuilt per-target archives, `shell` + `powershell` installers, a Homebrew formula in the declared `tap`, and an npm shim — attached to the single GitHub Release, alongside the crates.io publish. The pipeline stays the sole release authority: `dist` only builds (final URLs derive from repo + tag), and the release goes live through draft → undraft. Library crates self-skip. Adds `dist-plan`, `dist-build`, `dist-host`, `dist-publish` (gated by `cargo-deny` + `gitleaks`) and a `draft` input on `release/github-release`; optional `HOMEBREW_TAP_TOKEN` and `NPM_PACKAGE_REGISTRY_TOKEN` secrets activate the tap and npm publishes. +- `rust/base`, `rust/native-deps` — composites. `rust/base` installs the `rust-toolchain.toml` channel, caches `~/.cargo` + `target/`, runs the optional `ci/setup.sh` native-dependency hook, then lints and tests; `rust/native-deps` is that hook, shared with the `dist-build` matrix. +- `javascript/base` — wrap `pnpm install` in Socket Firewall (`sfw`), blocking confirmed-malicious packages before download. Fail-closed. The GitHub-runner equivalent of the image-baked firewall on GitLab. +- `self-release` — move the rolling `v0` major tag to each stable `coroboros/ci` release, so `@v0` consumers track the latest release without a manual tag push. +- `secrets` gate — gitleaks gates `publish` via `needs:` in the npm and Rust package workflows, alongside the supply-chain gate. A leaked secret blocks the release through the template's job graph, not the consumer's branch protection — parity with the GitLab `security-gate` stage. + +### Fixes +- `rust-packages` — gate `publish` on `dist-build` (`needs:` plus an explicit `!cancelled() && … && needs.dist-build.result != 'failure'`), so a failed binary build never produces a crates.io publish while the GitHub release stays a draft; a library crate (`dist-build` skipped) still publishes. Add a `concurrency` group that serializes a repo's releases — keyed per-repo on tags so one release's commit-back can't race another's, per-ref on branches where superseded CI cancels — and a `git pull --rebase` retry around the Homebrew tap push so concurrent releases don't clobber the shared tap. +- `javascript-npm-packages` — add the same ref-keyed `concurrency` group as `rust-packages`, so two release tags can't race `release/commit-artifacts`' push to `main`. +- `rust/base` — install the `rust-toolchain.toml` channel explicitly (`rustup toolchain install` with `rustfmt` + `clippy`) instead of relying on lazy auto-resolution on the first `cargo` invocation. +- `rust-packages` — pass `--provenance` on both npm-shim publish paths (token bootstrap and OIDC), attesting the shim via the job's `id-token` regardless of the auth path. +- `javascript-npm-packages` — gate `publish` on a new `supply-chain` job (osv-scanner, `needs:`). A known vulnerability now blocks the npm release; previously osv-scanner ran only in the parallel `security` job and could not stop a publish. +- `release` — drop the "move rolling major tag" step from the npm and Rust publish jobs. Reusable workflows run in the caller's context, so it force-pushed a meaningless `vN` ref into every consumer repo; the `v0` ref now moves on `coroboros/ci`'s own release (see `self-release`). +- `rust-packages` — pin the `dist-plan`, `dist-build`, `dist-host` checkouts to the tag commit (`ref: ${{ github.sha }}`) rather than the moving `main`, dropping the unused `fetch-depth: 0` and `dist-plan`'s now-redundant `verify-tag`. The per-target binaries build the exact source the tag points to regardless of the `publish` commit-back timing; `verify-tag` stays on the `publish` jobs, the only ones that check out `main` to push back. +- `rust-packages` — `dist-plan` detects binary distribution from `[package.metadata.dist]` or `[workspace.metadata.dist]`, so a cargo-dist 0.32 workspace-layout consumer isn't misread as a library crate. +- `rust-packages` — guard `cargo publish` with a `git status --porcelain` allowlist (`Cargo.toml`/`Cargo.lock`/`CHANGELOG.md`); an unexpected dirty file now fails the release before the immutable crates.io publish instead of shipping silently under `--allow-dirty`. + +### Refactor +- `rust-packages`, `javascript-npm-packages` — rename the `secrets` gate job to `secret-scan`, disambiguating it from the workflow's `secrets:` block; the dependent `publish` (and `rust-packages` `dist-build`) `needs:` follow. +- `security` — extract the gitleaks, osv-scanner, and cargo-deny scanners into `security/*` composites (single source). `security.yml` references them and the package `supply-chain` gates reuse them, so osv-scanner is defined once. The osv composite scans only when a supported manifest is present (`pnpm-lock.yaml`, `Cargo.lock`, `go.mod`, and the rest), so a dependency-less repo wiring in `security.yml` skips the scan instead of failing on osv's no-manifest error. `self-security.yml` runs the composites via local refs to self-test them pre-release. The `cargo-deny` composite imposes a canonical `security/deny.toml` via `--config` — a consumer `deny.toml` is ignored and a `deny.exceptions.toml` is rejected. +- `release/commit-artifacts` — extract the commit-back step shared by the npm and Rust publish jobs into a composite (`files` input, `[skip ci]`), mirroring GitLab's `commit-release-artifacts`. +- `release/verify-tag`, `rust/pin-version` — extract two blocks that were duplicated across the tag-time jobs into composites (single source). `release/verify-tag` is the "main HEAD matches the tag SHA" guard shared by the npm and Rust `publish` jobs; `rust/pin-version` is the `cargo-set-version` install + `cargo set-version` stamp shared by `publish` and the three `dist-*` jobs. +- `security/cargo-deny` — drop the redundant `--deny unmaintained --deny unsound` CLI flags. The imposed `deny.toml` already errors on both at `"all"` scope (the v2 advisories schema cannot downgrade them), so the policy stays single-sourced in the ruleset. +- `rust/pin-version` — move the `cargo-edit` version pin into the composite as a step-level env; it lived at the `rust-packages` workflow level. The composite is now self-contained like `security/gitleaks`, with the pin declared where it is consumed and callable standalone. +- `rust/install-dist` — new composite installing cargo-dist's `dist` from the prebuilt, SHA-256-verified release tarball (per-OS: Linux/macOS/Windows), shared by `dist-plan`/`dist-build`/`dist-host`. Replaces a multi-minute `cargo install --locked` from-source compile in each and satisfies the binary-pinning rule (version + checksum, like `security/gitleaks`); the version pin lives in the composite. +- `rust/pin-version` — install only the `cargo-set-version` binary (`cargo install --bin`), the one subcommand used, instead of all of cargo-edit — ~75% less from-source build. cargo-edit ships no prebuilt binary, so the source install stays. + +### Performance +- `rust/base`, `dist-build` — replace the hand-rolled `~/.cargo` cache with `Swatinem/rust-cache` (`v2.9.1`), caching `~/.cargo` + `target/` soundly: it keeps `-sys` / CMake `out/` dirs and `registry/src`, prunes the rest, and forces `CARGO_INCREMENTAL=0` (so a C++/CMake build is not recompiled every run, and the installed `cargo-set-version` is cached). `dist-build` keys the cache per target triple to avoid cross-leg poisoning; `rust/base` writes only from the default branch. + +### Tests +- `self-actions` — smoke `rust/native-deps` target passthrough (empty on host preflight, the exported triple on a cross leg) and `rust/test-deps` (absent → no-op; present → `ci/test-setup.sh` runs and `ci/test.env` propagates to the job environment). +- `self-actions` — new self-CI workflow exercising the composites on every PR via local refs: `release/verify-tag` (HEAD match + moved-HEAD failure), `release/generate-changelog` (SemVer-gate rejection of a non-tag ref), `release/commit-artifacts` (push / no-op / `FILES` word-split against a local bare remote, `contents: read` backstop), `security/cargo-deny` (consumer-override reject guard), and `rust/install-dist` (download + checksum + run on Linux, macOS, Windows — the zip and tarball extraction paths differ). A regression in the reachable logic now fails the PR. Tag-driven paths that read the runner's `GITHUB_REF_NAME`/`GITHUB_SHA` (generate-changelog auto-gen, pin-version stamp) can't be faked on a non-tag event — those reserved defaults aren't overridable into a composite — so they stay validated at real release time. + +### Documentation +- `README`, `CLAUDE.md` — document the native-CLI consumer contract (`ci/setup.sh` target-aware via `CARGO_DIST_TARGET`, `ci/test.env`, `ci/test-setup.sh`; each optional and a no-op when absent), the `deny.toml` escape-hatch for an unfixable transitive advisory (a justified central `ignore`, never per repo), and that the shared dist binaries are CPU-only so a consumer may keep a supplemental per-package workflow. +- `README`, `SECURITY.md` — document the Rust supply-chain model (`cargo-deny` baseline, residual `build.rs` and no-cooldown gaps), the Socket Firewall and release-age cooldown, and the publish auth paths; collapse cross-references to remove duplication; add the `sfw` proxy-inspection caveat for parity with GitLab; document the opt-in binary-distribution layer (jobs, consumer contract, optional tap/npm secrets) and the imposed `cargo-deny` ruleset. +- `README` — correct the `release/verify-tag` row (shared by the npm and Rust `publish` jobs, not "every job that acts on `main`"; the `dist-*` jobs pin to the tag commit) and the cargo-dist install note (`rust/install-dist`, prebuilt + SHA-256 verified); add the `rust/install-dist` composable row. +- `README` — binary-distribution consumer contract: cargo-dist `0.32` takes its workspace-global keys (`cargo-dist-version`/`ci`/`publish-jobs`/`allow-dirty`) from `[workspace.metadata.dist]` only, and needs `allow-dirty = ["ci"]` so `dist plan` doesn't claim its own workflow — without these a binary consumer's `dist plan` fails. + +### Configuration +- `package.json` — bump to `0.2.0` (was `0.1.13`, lagging the `0.1.14` tag). +- `renovate.json` + `renovate.yml` — self-hosted Renovate (scheduled workflow, `RENOVATE_TOKEN` PAT) auto-bumps the version-pinned tooling (gitleaks, actionlint, yamllint, cargo-dist, cargo-edit) via review-gated PRs, scoped so Dependabot keeps the action SHAs. A `postUpgradeTask` (`.github/renovate/sync-tool-sha.sh`) re-syncs each paired tarball SHA-256 to the bumped version 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..b0455b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,14 @@ 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` / `supply-chain` / `publish` / `security`). +- `.github/workflows/rust-packages.yml` — bundled Cargo pipeline (`preflight` matrix / `supply-chain` / `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.yml` — `gitleaks` + `dependency-review` + `osv-scanner` (gitleaks/osv wrap `security/*` composites; the package `supply-chain` + `secret-scan` gates reuse them). The `secret-scan` gate re-runs gitleaks so `publish` can `needs:` it (a job nested in `security.yml` isn't addressable); the duplicate run in `security.yml` is accepted for standalone consumers. +- `.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,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 +24,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 +43,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..ea06387 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,45 @@ Consumer requirements: +
+supply-chain + +
+ +**Trigger**: `every push`. Gates `publish` via `needs:` — a release waits on it. + +`osv-scanner` scans `pnpm-lock.yaml` against [OSV.dev](https://osv.dev/). `javascript/base` already gates the install (Socket Firewall + `--frozen-lockfile`); this adds the known-CVE gate so a vulnerable dependency blocks the release, not just the parallel `security` scan. See [Security](#security). + +
+ +
+secret-scan + +
+ +**Trigger**: `every push`. Gates `publish` via `needs:` — a release waits on it. + +`gitleaks` scans the full git history with the canonical ruleset. The release-gating twin of the parallel `security` scan, so a leaked secret blocks the publish through the template's `needs:` rather than per-repo branch protection. See [Security](#security). + +
+
publish
-**Trigger**: `tag push` +**Trigger**: `tag push`. Gated by `supply-chain` and `secret-scan` (`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)
@@ -90,19 +111,128 @@ Calls `security.yml` — 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) + +
+ +
+supply-chain + +
+ +**Trigger**: `every push`. Gates `publish` via `needs:` — a release waits on it. + +`cargo-deny check` (SHA-pinned action) applies the canonical imposed `security/deny.toml`: crate sources, licenses, bans, and advisories (vulnerabilities, unmaintained, unsound, yanked). Running on every push re-checks a tagged release against the latest advisory DB before it ships, not only at PR time. See [Security](#security). + +
+ +
+secret-scan + +
+ +**Trigger**: `every push`. Gates `publish` via `needs:` — a release waits on it. + +`gitleaks` scans the full git history with the canonical ruleset. The release-gating twin of the parallel `security` scan, so a leaked secret blocks the publish through the template's `needs:` rather than per-repo branch protection. 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 `supply-chain` and `secret-scan` (`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 `supply-chain` and `secret-scan` (`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 `security.yml`. + +
+ ### `security.yml` Reusable sub-workflow with three parallel scans: - **`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. +- **`osv-scanner`** — Scans dependency manifests recursively against [OSV.dev](https://osv.dev/) via `google/osv-scanner-action@v2`, failing on any known vulnerability. Runs only when a supported manifest is present — `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`. A repo with none — docs, config — skips the scan rather than failing on osv's no-manifest error. Per-repo exceptions go in an `osv-scanner.toml` at the repo root (`[[IgnoredVulns]]`). Extend the list in the `security/osv-scanner` composite as ecosystems land. -Imposed on every Coroboros workflow. Standalone wire-up — see [Examples](#examples). +`gitleaks` and `osv-scanner` wrap the [`security/*` composites](#composable-actions) — the same definitions the package `supply-chain` gates reuse; `dependency-review` is inline. Imposed on every Coroboros workflow. Standalone wire-up — see [Examples](#examples). --- -**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`. Jobs run in parallel except each `publish`, which `needs:` the `supply-chain` and secret-scanning gates so the release is re-checked (cargo-deny or osv-scanner, plus gitleaks) before it ships. `security.yml` scans in parallel for reporting — it does not gate `publish` (see [Security](#security)). The only sub-workflow call is `security` → `security.yml`. --- @@ -112,8 +242,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. The shared definition behind `security.yml`, the package secret-scanning gate, and self-CI. | +| `security/osv-scanner` | transverse | Scans dependency manifests for known vulnerabilities (OSV.dev); skips a repo with no supported manifest. Shared by `security.yml`, the npm `supply-chain` gate, and self-CI. | +| `security/cargo-deny` | Rust | Runs cargo-deny against the canonical imposed `security/deny.toml` (sparse-checked from `coroboros/ci`, no consumer override). The Rust `supply-chain` gate. | +| `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 +302,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 +320,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:`.
@@ -223,7 +373,7 @@ Caller job needs `permissions: contents: write`. Uses `${{ github.token }}` inte
-`pnpm install --frozen-lockfile --ignore-scripts` runs inside `javascript/base`. +`sfw pnpm install --frozen-lockfile --ignore-scripts` runs inside `javascript/base` (Socket Firewall wraps the fetch — see Firewall below). - `--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. @@ -232,6 +382,60 @@ pnpm CLI resolved via corepack from `packageManager`. No floating version reache +
+Firewall — Socket Firewall (sfw) + +
+ +`javascript/base` installs Socket Firewall (`npm i -g sfw`, the free tokenless build) and runs the install through it (`sfw pnpm install ...`). `sfw` proxies the registry fetch and blocks packages Socket has confirmed malicious before they download. It is the GitHub-runner equivalent of the image-baked firewall in the GitLab pipeline. + +Fail-closed: if `sfw` cannot install or run, the install step fails rather than fetching unprotected. The free build needs no account or token, and inspects public-registry fetches out of the box. Packages pulled through a private proxy registry pass uninspected — `sfw` knows the public npm registry, and the release-age [cooldown](#security) still covers them. The trade-off of the runtime install (no shared image) is a dependency on Socket's service at fetch time. + +
+ +
+Cooldown — minimum release age + +
+ +Quarantine freshly published versions so a hijacked release is not pulled inside the window most campaigns are caught in. pnpm 11 reads the setting from `pnpm-workspace.yaml`, not `.npmrc`. Add to the consuming repo: + +```yaml +# pnpm-workspace.yaml +minimumReleaseAge: 10080 # 7 days, in minutes +minimumReleaseAgeExclude: + - '@coroboros/*' # internal packages install immediately +``` + +On pnpm 10.x the `.npmrc` form `minimum-release-age=10080` also works. `@coroboros/*` is excluded so a pipeline can consume a just-published internal package. + +
+ +
+Supply chain — Rust (rust-packages.yml) + +
+ +The GitLab pipeline hardens npm at the image layer — cooldown, Socket Firewall, `--ignore-scripts`, digest pins. GitHub-hosted runners share no base image, so the Rust pipeline enforces the same risk model 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 | `osv-scanner` (Cargo.lock) and `cargo-deny` advisories — RustSec vulnerabilities, unmaintained, unsound, yanked | +| License drift | `cargo-deny` licenses — allow-list | +| Banned or wildcard dependency | `cargo-deny` bans | + +`cargo-deny` runs on every push via a SHA-pinned action and gates `publish` (`needs:`), so a tagged release is re-checked against the latest advisory DB before it ships — `security.yml` scans in parallel for reporting and does not block the release. The controls above are **imposed**: it 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`. + +**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. + +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. + +
+
Recommended NPM_CONFIG_FILE contents @@ -256,7 +460,7 @@ prefer-online=true | `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`. | +| `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 +523,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.
@@ -359,6 +563,37 @@ jobs: +
+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.yml standalone (non-npm repo) @@ -427,7 +662,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 }