diff --git a/.github/workflows/advisories.yml b/.github/workflows/advisories.yml new file mode 100644 index 0000000..d49703a --- /dev/null +++ b/.github/workflows/advisories.yml @@ -0,0 +1,32 @@ +name: Dependency advisories +on: + schedule: + - cron: '41 6 * * *' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: client-dependency-advisories + cancel-in-progress: true +jobs: + advisories: + if: github.ref == 'refs/heads/main' + name: Main branch advisories + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned cargo-deny + run: | + install -d "${RUNNER_TEMP}/tools/cargo-deny" "${RUNNER_TEMP}/bin" + curl --fail --silent --show-error --location https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz --output "${RUNNER_TEMP}/cargo-deny.tar.gz" + printf '%s %s\n' 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f "${RUNNER_TEMP}/cargo-deny.tar.gz" | sha256sum --check --strict + tar -xzf "${RUNNER_TEMP}/cargo-deny.tar.gz" -C "${RUNNER_TEMP}/tools/cargo-deny" --strip-components=1 + install "${RUNNER_TEMP}/tools/cargo-deny/cargo-deny" "${RUNNER_TEMP}/bin/cargo-deny" + echo "${RUNNER_TEMP}/bin" >>"${GITHUB_PATH}" + - run: rustup toolchain install 1.97.1 --profile minimal + # Pull requests only fail on advisories they introduce, so this is where + # a newly published advisory against main's Cargo.lock shows up. + - run: tools/check-advisories.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d9a4e15..75e42a8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -40,6 +40,8 @@ jobs: tar -xzf "${RUNNER_TEMP}/syft.tar.gz" -C "${RUNNER_TEMP}/bin" syft echo "${RUNNER_TEMP}/bin" >>"${GITHUB_PATH}" - run: tools/validate.sh + env: + ADVISORY_BASE_REVISION: ${{ github.event.pull_request.base.sha }} windows: name: Windows build and tests diff --git a/README.md b/README.md index 375f3a9..ffefc3e 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ SDL_VIDEODRIVER=dummy cargo run --locked --package atrinik-client -- window Run `tools/validate.sh` for formatting, Clippy-as-errors, tests, architecture, provenance/parity, dependency/license/advisory, native-library, release, SBOM, -and reproducibility gates. +and reproducibility gates. On pull requests, `ADVISORY_BASE_REVISION` limits +the advisory gate to advisories the change introduces; ones already on `main` +are warnings there and fail the daily `Dependency advisories` workflow instead. Semantic-release creates an immutable version tag from `main` without exposing a partial GitHub release. A completion-triggered workflow resolves that exact diff --git a/tools/check-advisories.sh b/tools/check-advisories.sh new file mode 100755 index 0000000..91f7787 --- /dev/null +++ b/tools/check-advisories.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Atrinik contributors. SPDX-License-Identifier: MIT +# +# Fail on RustSec advisories that this change introduces. With +# ADVISORY_BASE_REVISION set (pull requests), advisories already present at +# that revision are reported as warnings so an advisory published against +# main does not fail unrelated pull requests. Without it (main, local runs, +# dependency candidates) every advisory fails. +set -euo pipefail +repository=$(git rev-parse --show-toplevel) +cd "${repository}" + +# Prints one "ID:crate@version" key per denied finding. Yanked crates carry no +# advisory ID, so the crate coordinates keep distinct findings distinct. +advisory_ids() { + local directory=$1 output status=0 + output=$(mktemp) + (cd "${directory}" && cargo deny --locked --format json check advisories 2>"${output}" >/dev/null) || status=$? + python3 - "${output}" "${status}" <<'PY' || { rm -f -- "${output}"; return 1; } +import json, sys +keys, summary = set(), False +for line in open(sys.argv[1], encoding='utf-8'): + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + fields = entry.get('fields', {}) + if entry.get('type') == 'summary': + summary = True + elif entry.get('type') == 'diagnostic' and fields.get('severity') == 'error': + advisory = fields.get('advisory') or {} + crates = [graph.get('Krate') or {} for graph in fields.get('graphs') or []] + coordinates = ','.join(sorted(f"{krate.get('name')}@{krate.get('version')}" for krate in crates)) or 'unknown' + keys.add(f"{advisory.get('id') or fields.get('code') or 'unknown'}:{coordinates}") +# A non-zero exit is only an advisory result when cargo-deny completed its +# check and reported the findings; anything else is an operational failure. +if int(sys.argv[2]) != 0 and not (summary and keys): + sys.exit(f'cargo-deny advisories check failed (exit {sys.argv[2]}) without reporting advisories:\n' + + open(sys.argv[1], encoding='utf-8').read()[-4000:]) +print('\n'.join(sorted(keys))) +PY + rm -f -- "${output}" +} + +head_ids=$(advisory_ids .) || exit 1 +if [ -z "${head_ids}" ]; then + echo "advisories ok" + exit 0 +fi + +base=${ADVISORY_BASE_REVISION:-} +if [ -z "${base}" ]; then + cargo deny --locked check advisories + exit 1 +fi + +worktree=$(mktemp -d /tmp/atrinik-client-advisory-base.XXXXXX) +trap 'git worktree remove --force "${worktree}" >/dev/null 2>&1 || rm -rf -- "${worktree}"' EXIT +git worktree add --detach --quiet "${worktree}" "${base}" +base_ids=$(advisory_ids "${worktree}") || exit 1 + +introduced=$(comm -23 <(printf '%s\n' "${head_ids}") <(printf '%s\n' "${base_ids}" | sed '/^$/d')) +if [ -n "${introduced}" ]; then + echo "advisories introduced by this change:" >&2 + printf ' %s\n' ${introduced} >&2 + cargo deny --locked check advisories + exit 1 +fi + +for id in ${head_ids}; do + echo "::warning title=Pre-existing dependency advisory::${id} is already present on the base revision; fix it on main (see tools/check-advisories.sh)." +done +echo "advisories present on base only: $(echo ${head_ids})" diff --git a/tools/validate.sh b/tools/validate.sh index a2a1cdb..983922a 100755 --- a/tools/validate.sh +++ b/tools/validate.sh @@ -12,7 +12,8 @@ cargo fmt --all --check cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace --all-targets cargo test --locked --workspace --doc -cargo deny --locked check +cargo deny --locked check bans licenses sources +tools/check-advisories.sh tools/check-foundations.sh cargo run --locked --quiet --package atrinik-client -- version cargo run --locked --quiet --package atrinik-client -- headless