diff --git a/.changes/changed-test-planning-now-f64b.md b/.changes/changed-test-planning-now-f64b.md new file mode 100644 index 00000000..68a5408d --- /dev/null +++ b/.changes/changed-test-planning-now-f64b.md @@ -0,0 +1,7 @@ +--- +"rscrypto" = "patch" +--- + +Changed-test planning now runs the workspace test suite when planner output is +unavailable, invalid, or ambiguous; only a valid explicit empty scope skips +tests. diff --git a/.changes/the-check-and-check-eb38.md b/.changes/the-check-and-check-eb38.md new file mode 100644 index 00000000..da3fc6ce --- /dev/null +++ b/.changes/the-check-and-check-eb38.md @@ -0,0 +1,6 @@ +--- +"rscrypto" = "patch" +--- + +The `check` and `check-all` recipes now report formatting and lockfile drift +without modifying pre-existing source or `Cargo.lock` changes. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 74cf62c6..8b54f961 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,10 +23,12 @@ jobs: if: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.actor != 'dependabot[bot]') }} runs-on: ubuntu-latest outputs: - build: ${{ steps.rail.outputs.build || 'true' }} - test: ${{ steps.rail.outputs.test || 'true' }} - infra: ${{ steps.rail.outputs.infra || 'true' }} - cargo_graph: ${{ steps.graph.outputs.enabled || 'true' }} + build: ${{ steps.resolve.outputs.build }} + test: ${{ steps.resolve.outputs.test }} + infra: ${{ steps.resolve.outputs.infra }} + cargo_graph: ${{ steps.resolve.outputs.cargo_graph }} + plan_valid: ${{ steps.resolve.outputs.valid }} + plan_empty: ${{ steps.resolve.outputs.empty }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -41,25 +43,20 @@ jobs: with: version: "0.18.0" + - name: Resolve Build Plan + if: always() + id: resolve + env: + RAIL_PLAN_STEP_OUTCOME: ${{ steps.rail.outcome }} + RAIL_SCOPE_JSON: ${{ steps.rail.outputs.scope-json }} + run: scripts/ci/resolve-rail-plan.sh + - name: Check Release Intent Coverage if: github.event_name == 'pull_request' env: RAIL_BASE_REF: ${{ steps.rail.outputs.base-ref }} run: cargo rail change check --since "$RAIL_BASE_REF" --required - - name: Resolve Cargo Graph Assurance - id: graph - env: - EVENT_NAME: ${{ github.event_name }} - RAIL_SCOPE_JSON: ${{ steps.rail.outputs.scope-json }} - run: | - if [[ "$EVENT_NAME" == "pull_request" ]]; then - enabled="$(jq -r '.surfaces["custom:cargo_graph"] // false' <<<"$RAIL_SCOPE_JSON")" - else - enabled=true - fi - echo "enabled=$enabled" >> "$GITHUB_OUTPUT" - dependency-smoke: name: Dependency Smoke if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && github.actor == 'dependabot[bot]' }} @@ -78,7 +75,7 @@ jobs: suite: name: CI Suite needs: rail-plan - if: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.actor != 'dependabot[bot]' && (needs.rail-plan.outputs.build == 'true' || needs.rail-plan.outputs.test == 'true' || needs.rail-plan.outputs.infra == 'true')) }} + if: ${{ always() && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.actor != 'dependabot[bot]' && (needs.rail-plan.result != 'success' || needs.rail-plan.outputs.build == 'true' || needs.rail-plan.outputs.test == 'true' || needs.rail-plan.outputs.infra == 'true'))) }} uses: ./.github/workflows/_ci-suite.yaml with: cache_key_prefix: commit @@ -97,7 +94,9 @@ jobs: ACTOR: ${{ github.actor }} DEPENDENCY_SMOKE_RESULT: ${{ needs.dependency-smoke.result }} EVENT_NAME: ${{ github.event_name }} + PLAN_EMPTY: ${{ needs.rail-plan.outputs.plan_empty }} PLAN_RESULT: ${{ needs.rail-plan.result }} + PLAN_VALID: ${{ needs.rail-plan.outputs.plan_valid }} SUITE_RESULT: ${{ needs.suite.result }} run: | if [[ "$EVENT_NAME" == "pull_request" && "$ACTOR" == "dependabot[bot]" ]]; then @@ -114,7 +113,12 @@ jobs: exit 1 fi - if [[ "$SUITE_RESULT" != "success" && "$SUITE_RESULT" != "skipped" ]]; then + if [[ "$SUITE_RESULT" == "skipped" ]]; then + if [[ "$PLAN_VALID" != "true" || "$PLAN_EMPTY" != "true" ]]; then + echo "CI suite skipped without a valid empty plan" + exit 1 + fi + elif [[ "$SUITE_RESULT" != "success" ]]; then echo "CI suite result: $SUITE_RESULT" exit 1 fi diff --git a/justfile b/justfile index 0dc8a39c..b3204127 100644 --- a/justfile +++ b/justfile @@ -245,6 +245,8 @@ check-actions: @scripts/ci/check-ci-ownership-test.sh @scripts/ci/run-rust-job-test.sh @scripts/ci/emit-manual-matrix-test.sh + @scripts/ci/changed-test-planning-test.sh + @scripts/ci/check-worktree-test.sh @scripts/ci/pre-push-test.sh @scripts/ci/release-evidence-check-test.sh @scripts/ci/repository-controls-evidence-test.sh diff --git a/scripts/README.md b/scripts/README.md index 1c78310b..52584f69 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -29,6 +29,8 @@ its caller. | `ci/check-ci-ownership-test.sh`| `just check-actions` | | `ci/run-rust-job-test.sh` | `just check-actions` | | `ci/emit-manual-matrix-test.sh`| `just check-actions` | +| `ci/changed-test-planning-test.sh` | `just check-actions` | +| `ci/check-worktree-test.sh` | `just check-actions` | | `ci/pre-push-test.sh` | `just check-actions` | | `ci/release-plan-check.sh` | `just release-check` | | `ci/release-evidence-check.sh` | `just release-tag`, `release.yaml`, `ci/release-evidence-check-test.sh` | @@ -76,6 +78,7 @@ claim in [`docs/secret-lifecycle.md`](../docs/secret-lifecycle.md). |--------|---------| | `ci/install-tools.sh` | `.github/actions/setup/action.yaml` | | `ci/run-rust-job.sh` | `.github/workflows/_rust-job.yaml` | +| `ci/resolve-rail-plan.sh` | `.github/workflows/ci.yaml` | | `ci/dependabot-smoke.sh` | `ci/run-rust-job.sh` | | `ci/emit-manual-matrix.sh` | `bench.yaml`, `ct.yaml` | | `ci/mlkem-aarch64-gate.sh` | `ci/run-rust-job.sh` | diff --git a/scripts/check/check-all.sh b/scripts/check/check-all.sh index 777f64d0..31ad9ff3 100755 --- a/scripts/check/check-all.sh +++ b/scripts/check/check-all.sh @@ -90,6 +90,7 @@ run_constrained_check() { --no-default-features --target "$target" --lib + --locked ) if [[ -n "$feature_set" ]]; then @@ -146,7 +147,7 @@ run_constrained_target() { step "$target check (alloc)" for crate in "${alloc_crates[@]}"; do if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ - cargo check -p "$crate" --no-default-features --features alloc --target "$target" --lib \ + cargo check --locked -p "$crate" --no-default-features --features alloc --target "$target" --lib \ >>"$log_file" 2>&1; then fail show_error "$log_file" @@ -159,7 +160,7 @@ run_constrained_target() { step "$target build (no features)" for crate in "${CONSTRAINED_CRATES[@]}"; do if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ - cargo build -p "$crate" --no-default-features --target "$target" --lib --release \ + cargo build --locked -p "$crate" --no-default-features --target "$target" --lib --release \ >>"$log_file" 2>&1; then fail show_error "$log_file" @@ -172,7 +173,7 @@ run_constrained_target() { step "$target build (alloc)" for crate in "${alloc_crates[@]}"; do if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ - cargo build -p "$crate" --no-default-features --features alloc --target "$target" --lib --release \ + cargo build --locked -p "$crate" --no-default-features --features alloc --target "$target" --lib --release \ >>"$log_file" 2>&1; then fail show_error "$log_file" @@ -188,7 +189,10 @@ run_constrained_checks() { log_dir=$(mktemp -d) trap 'rm -rf "$log_dir"' RETURN - local constrained_targets=("${NOSTD_TARGETS[@]}" "${WASM_TARGETS[@]}") + local constrained_targets=( + "${NOSTD_TARGETS[@]:+${NOSTD_TARGETS[@]}}" + "${WASM_TARGETS[@]:+${WASM_TARGETS[@]}}" + ) echo "" echo "Constrained targets ${DIM}($CONSTRAINED_SCOPE)${RESET}" @@ -246,7 +250,7 @@ done if [[ "$FEATURE_MODE_SET" == false ]]; then HOST_ARGS+=(--feature-matrix) fi -select_constrained_crates "${SCOPE_ARGS[@]}" +select_constrained_crates "${SCOPE_ARGS[@]:+${SCOPE_ARGS[@]}}" # Run host checks first (most likely to fail, fastest feedback) "$SCRIPT_DIR/check.sh" "${HOST_ARGS[@]}" @@ -260,13 +264,13 @@ trap 'rm -rf "$log_dir"' EXIT jobs=(windows linux ibm constrained) pids=() -("$SCRIPT_DIR/check-win.sh" "${SCOPE_ARGS[@]}") >"$log_dir/windows.log" 2>&1 & +("$SCRIPT_DIR/check-win.sh" "${SCOPE_ARGS[@]:+${SCOPE_ARGS[@]}}") >"$log_dir/windows.log" 2>&1 & pids+=( "$!" ) -("$SCRIPT_DIR/check-linux.sh" "${SCOPE_ARGS[@]}") >"$log_dir/linux.log" 2>&1 & +("$SCRIPT_DIR/check-linux.sh" "${SCOPE_ARGS[@]:+${SCOPE_ARGS[@]}}") >"$log_dir/linux.log" 2>&1 & pids+=( "$!" ) -("$SCRIPT_DIR/check-ibm.sh" "${SCOPE_ARGS[@]}") >"$log_dir/ibm.log" 2>&1 & +("$SCRIPT_DIR/check-ibm.sh" "${SCOPE_ARGS[@]:+${SCOPE_ARGS[@]}}") >"$log_dir/ibm.log" 2>&1 & pids+=( "$!" ) (run_constrained_checks) >"$log_dir/constrained.log" 2>&1 & diff --git a/scripts/check/check-feature-matrix.sh b/scripts/check/check-feature-matrix.sh index 50d0b62b..7e647dc4 100755 --- a/scripts/check/check-feature-matrix.sh +++ b/scripts/check/check-feature-matrix.sh @@ -54,7 +54,7 @@ for i in "${!FEATURE_SETS[@]}"; do log_path="$LOG_DIR/${display//,/_}.log" step "[$profile/$TOTAL] cargo check --no-default-features --features $display" - args=(check -p rscrypto --no-default-features --lib) + args=(check --locked -p rscrypto --no-default-features --lib) if [[ -n "$feature_set" ]]; then args+=(--features "$feature_set") fi diff --git a/scripts/check/check-ibm.sh b/scripts/check/check-ibm.sh index ef3c8f9b..c9ce4008 100755 --- a/scripts/check/check-ibm.sh +++ b/scripts/check/check-ibm.sh @@ -54,7 +54,7 @@ for i in "${!IBM_TARGETS[@]}"; do ( # shellcheck disable=SC2086 if ! CC="$ZIG_CC" RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ - cargo clippy $CRATE_FLAGS --lib --all-features --target "$target" -- -D warnings \ + cargo clippy $CRATE_FLAGS --lib --all-features --locked --target "$target" -- -D warnings \ >"$log_file" 2>&1; then exit 1 fi diff --git a/scripts/check/check-linux.sh b/scripts/check/check-linux.sh index a60136ab..f35420d3 100755 --- a/scripts/check/check-linux.sh +++ b/scripts/check/check-linux.sh @@ -54,7 +54,7 @@ for i in "${!LINUX_TARGETS[@]}"; do ( # shellcheck disable=SC2086 if ! CC="$ZIG_CC" RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ - cargo clippy $CRATE_FLAGS --lib --all-features --target "$target" -- -D warnings \ + cargo clippy $CRATE_FLAGS --lib --all-features --locked --target "$target" -- -D warnings \ >"$log_file" 2>&1; then exit 1 fi diff --git a/scripts/check/check-win.sh b/scripts/check/check-win.sh index 6e408c0e..decebde9 100755 --- a/scripts/check/check-win.sh +++ b/scripts/check/check-win.sh @@ -42,7 +42,7 @@ fi step "Initializing SDK cache" if ! XWIN_CACHE_DIR="$XWIN_CACHE_DIR_DEFAULT" \ CARGO_TARGET_DIR="target/cross-check/xwin-init" \ - cargo xwin check -p rscrypto --no-default-features --lib --target x86_64-pc-windows-msvc \ + cargo xwin check --locked -p rscrypto --no-default-features --lib --target x86_64-pc-windows-msvc \ >"$LOG_DIR/xwin-init.log" 2>&1; then # In sandboxed/offline environments, cargo-xwin can't fetch the MSVC CRT / SDK. # Treat that as a skip rather than a hard failure, while still surfacing @@ -85,7 +85,7 @@ for i in "${!WIN_TARGETS[@]}"; do # shellcheck disable=SC2086 if ! XWIN_CACHE_DIR="$target_cache_dir" \ CARGO_TARGET_DIR="$target_dir" \ - cargo xwin clippy $CRATE_FLAGS --lib --all-features --target "$target" -- -D warnings \ + cargo xwin clippy $CRATE_FLAGS --lib --all-features --locked --target "$target" -- -D warnings \ >"$log_file" 2>&1; then exit 1 fi diff --git a/scripts/check/check.sh b/scripts/check/check.sh index ba4c7acc..c1d09c33 100755 --- a/scripts/check/check.sh +++ b/scripts/check/check.sh @@ -60,7 +60,7 @@ echo "Host checks ${DIM}($SCOPE_DESC)${RESET}" # Format step "Formatting" -if ! cargo fmt --all >"$LOG_DIR/fmt.log" 2>&1; then +if ! cargo fmt --all -- --check >"$LOG_DIR/fmt.log" 2>&1; then fail show_error "$LOG_DIR/fmt.log" exit 1 @@ -94,7 +94,7 @@ ok # Check step "Checking" # shellcheck disable=SC2086 -if ! cargo check $CRATE_FLAGS --all-targets --all-features >"$LOG_DIR/check.log" 2>&1; then +if ! cargo check $CRATE_FLAGS --all-targets --all-features --locked >"$LOG_DIR/check.log" 2>&1; then fail show_error "$LOG_DIR/check.log" exit 1 @@ -124,7 +124,7 @@ fi # Clippy step "Linting" # shellcheck disable=SC2086 -if ! cargo clippy $CRATE_FLAGS --all-targets --all-features -- -D warnings >"$LOG_DIR/clippy.log" 2>&1; then +if ! cargo clippy $CRATE_FLAGS --all-targets --all-features --locked -- -D warnings >"$LOG_DIR/clippy.log" 2>&1; then fail show_error "$LOG_DIR/clippy.log" exit 1 @@ -134,7 +134,7 @@ ok # Audit/Deny (workspace only). CI owns this in the dedicated supply-chain lane. if [[ "$FULL_WORKSPACE" == true && "${RSCRYPTO_SKIP_CHECK_SUPPLY_CHAIN:-}" != "1" ]]; then step "Auditing deps" - if ! cargo deny check all >"$LOG_DIR/deny.log" 2>&1; then + if ! cargo deny --locked check all >"$LOG_DIR/deny.log" 2>&1; then fail show_error "$LOG_DIR/deny.log" exit 1 @@ -153,7 +153,7 @@ fi # Documentation step "Building docs" # shellcheck disable=SC2086 -if ! cargo doc $CRATE_FLAGS --no-deps --all-features >"$LOG_DIR/doc.log" 2>&1; then +if ! cargo doc $CRATE_FLAGS --no-deps --all-features --locked >"$LOG_DIR/doc.log" 2>&1; then fail show_error "$LOG_DIR/doc.log" exit 1 diff --git a/scripts/check/zeroize-evidence.sh b/scripts/check/zeroize-evidence.sh index 0e0fdeb2..ba6833db 100755 --- a/scripts/check/zeroize-evidence.sh +++ b/scripts/check/zeroize-evidence.sh @@ -6,6 +6,7 @@ TARGET_DIR="$ROOT/target/zeroize-evidence" MANIFEST="$ROOT/Cargo.toml" RUSTC_WRAPPER="" CARGO_TARGET_DIR="$TARGET_DIR" cargo rustc \ + --locked \ --manifest-path "$MANIFEST" \ --release \ --lib \ diff --git a/scripts/ci/changed-test-planning-test.sh b/scripts/ci/changed-test-planning-test.sh new file mode 100755 index 00000000..346417f0 --- /dev/null +++ b/scripts/ci/changed-test-planning-test.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +fail() { + echo "changed-test planning regression failure: $*" >&2 + exit 1 +} + +assert_eq() { + local expected=$1 + local actual=$2 + local description=$3 + if [[ "$actual" != "$expected" ]]; then + printf 'expected %s: %q\nactual: %q\n' "$description" "$expected" "$actual" >&2 + exit 1 + fi +} + +make_scope() { + local mode=$1 + local crates=$2 + local cargo_args=$3 + local build=${4:-false} + local test=${5:-false} + local infra=${6:-false} + + jq -cn \ + --arg mode "$mode" \ + --argjson crates "$crates" \ + --argjson cargo_args "$cargo_args" \ + --argjson build "$build" \ + --argjson test "$test" \ + --argjson infra "$infra" \ + '{ + scope_contract_version: 2, + resolved_base: "base", + resolved_head: "head", + mode: $mode, + crates: $crates, + cargo_args: $cargo_args, + surfaces: { + bench: false, + build: $build, + "custom:cargo_graph": false, + docs: false, + infra: $infra, + test: $test + } + }' +} + +make_plan() { + local scope=$1 + local files=${2:-'[]'} + + jq -cn \ + --argjson scope "$scope" \ + --argjson files "$files" \ + '{ + schema_version: 1, + command: "plan", + mode: "inspect", + result: "success", + exit_code: 0, + plan_contract_version: 3, + files: $files, + scope: $scope + }' +} + +EMPTY_SCOPE="$(make_scope empty '[]' '[]')" +WORKSPACE_SCOPE="$(make_scope workspace '[]' '["--workspace"]' true true false)" +CRATES_SCOPE="$(make_scope crates '["crate-a","crate-b"]' '["-p","crate-a","-p","crate-b"]' true true false)" +EMPTY_PLAN="$(make_plan "$EMPTY_SCOPE")" +WORKSPACE_PLAN="$(make_plan "$WORKSPACE_SCOPE")" +CRATES_PLAN="$(make_plan "$CRATES_SCOPE")" + +planner_bin="$TMP_ROOT/planner-bin" +mkdir -p "$planner_bin" +cat >"$planner_bin/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "rail" && "${2:-}" == "plan" ]]; then + printf '%s' "${MOCK_PLAN_OUTPUT:-}" + exit "${MOCK_PLAN_STATUS:-0}" +fi +exit 0 +EOF +chmod +x "$planner_bin/cargo" + +scope_mode_for_cached_plan() ( + unset RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + export RAIL_PLAN_JSON_CACHE=$1 + # shellcheck source=../lib/rail-plan.sh + source "$REPO_ROOT/scripts/lib/rail-plan.sh" + rail_scope_mode +) + +scope_mode_for_planner() ( + unset RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + export PATH="$planner_bin:$PATH" + export MOCK_PLAN_OUTPUT=$1 + export MOCK_PLAN_STATUS=$2 + # shellcheck source=../lib/rail-plan.sh + source "$REPO_ROOT/scripts/lib/rail-plan.sh" + rail_scope_mode +) + +assert_eq workspace "$(scope_mode_for_planner '' 9)" "nonzero planner exit to fail closed" +assert_eq workspace "$(scope_mode_for_planner '' 0)" "empty planner output to fail closed" +assert_eq workspace "$(scope_mode_for_cached_plan '{')" "malformed JSON to fail closed" + +unsuccessful_plan="$(jq -c '.result = "failure" | .exit_code = 1' <<<"$WORKSPACE_PLAN")" +assert_eq workspace "$(scope_mode_for_cached_plan "$unsuccessful_plan")" "unsuccessful result to fail closed" + +missing_scope="$(jq -c 'del(.scope)' <<<"$WORKSPACE_PLAN")" +missing_mode="$(jq -c 'del(.scope.mode)' <<<"$WORKSPACE_PLAN")" +unsupported_mode="$(jq -c '.scope.mode = "packages"' <<<"$WORKSPACE_PLAN")" +assert_eq workspace "$(scope_mode_for_cached_plan "$missing_scope")" "missing scope to fail closed" +assert_eq workspace "$(scope_mode_for_cached_plan "$missing_mode")" "missing mode to fail closed" +assert_eq workspace "$(scope_mode_for_cached_plan "$unsupported_mode")" "unsupported mode to fail closed" + +empty_crate_selection="$(jq -c '.scope.mode = "crates" | .scope.cargo_args = []' <<<"$WORKSPACE_PLAN")" +non_string_crate="$(jq -c '.scope.crates = [7] | .scope.mode = "crates" | .scope.cargo_args = ["-p", "7"]' <<<"$WORKSPACE_PLAN")" +malformed_surface="$(jq -c '.scope.surfaces.test = "false"' <<<"$WORKSPACE_PLAN")" +assert_eq workspace "$(scope_mode_for_cached_plan "$empty_crate_selection")" "empty crate selection to fail closed" +assert_eq workspace "$(scope_mode_for_cached_plan "$non_string_crate")" "non-string crate selection to fail closed" +assert_eq workspace "$(scope_mode_for_cached_plan "$malformed_surface")" "malformed surface to fail closed" + +assert_eq empty "$(scope_mode_for_cached_plan "$EMPTY_PLAN")" "valid explicit empty scope" +assert_eq workspace "$(scope_mode_for_cached_plan "$WORKSPACE_PLAN")" "valid workspace scope" +assert_eq crates "$(scope_mode_for_cached_plan "$CRATES_PLAN")" "valid crate scope" + +crate_output="$( + ( + export RAIL_PLAN_JSON_CACHE="$CRATES_PLAN" + unset RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + # shellcheck source=../lib/rail-plan.sh + source "$REPO_ROOT/scripts/lib/rail-plan.sh" + rail_plan_crates + ) 2>/dev/null +)" +assert_eq $'crate-a\ncrate-b' "$crate_output" "validated crate selection" + +command_bin="$TMP_ROOT/command-bin" +command_home="$TMP_ROOT/command-home" +command_log="$TMP_ROOT/commands.log" +jq_dir="$(dirname "$(command -v jq)")" +mkdir -p "$command_bin" "$command_home/.cargo/bin" +cat >"$command_bin/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'cargo %s\n' "$*" >>"$MOCK_LOG" +if [[ "${1:-}" == "rail" && "${2:-}" == "plan" ]]; then + printf '%s' "${MOCK_PLAN_OUTPUT:-}" + exit "${MOCK_PLAN_STATUS:-0}" +fi +exit "${MOCK_CARGO_STATUS:-0}" +EOF +chmod +x "$command_bin/cargo" +cp "$command_bin/cargo" "$command_home/.cargo/bin/cargo" + +run_test_consumer() { + local name=$1 + local plan=$2 + local status=$3 + local expected=$4 + local output="$TMP_ROOT/test-$name.out" + : >"$command_log" + + if ! ( + unset RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + export HOME="$command_home" + export PATH="$command_bin:$jq_dir:/usr/bin:/bin" + export MOCK_LOG="$command_log" + export MOCK_PLAN_OUTPUT="$plan" + export MOCK_PLAN_STATUS="$status" + export RSCRYPTO_SKIP_DOCTESTS=true + cd "$REPO_ROOT" + bash scripts/test/test.sh + ) >"$output" 2>&1; then + cat "$output" >&2 + fail "test consumer failed for $name" + fi + + local actual + actual="$(grep '^cargo test' "$command_log" || true)" + assert_eq "$expected" "$actual" "test commands for $name" +} + +workspace_test='cargo test --workspace --all-features --lib --tests' +run_test_consumer planner-failure '' 9 "$workspace_test" +run_test_consumer empty-output '' 0 "$workspace_test" +run_test_consumer malformed-json '{' 0 "$workspace_test" +run_test_consumer unsuccessful "$unsuccessful_plan" 0 "$workspace_test" +run_test_consumer missing-scope "$missing_scope" 0 "$workspace_test" +run_test_consumer missing-mode "$missing_mode" 0 "$workspace_test" +run_test_consumer unsupported-mode "$unsupported_mode" 0 "$workspace_test" +run_test_consumer malformed-crates "$empty_crate_selection" 0 "$workspace_test" +run_test_consumer malformed-surface "$malformed_surface" 0 "$workspace_test" +run_test_consumer valid-empty "$EMPTY_PLAN" 0 '' +run_test_consumer valid-workspace "$WORKSPACE_PLAN" 0 "$workspace_test" +run_test_consumer valid-crates "$CRATES_PLAN" 0 $'cargo test -p crate-a --all-features --lib --tests\ncargo test -p crate-b --all-features --lib --tests' + +check_fixture="$TMP_ROOT/check-repository" +mkdir -p "$check_fixture/scripts/check" "$check_fixture/scripts/lib" "$check_fixture/scripts/ct" "$check_fixture/scripts/test" +cp "$REPO_ROOT/scripts/check/check.sh" "$check_fixture/scripts/check/check.sh" +cp "$REPO_ROOT/scripts/lib/common.sh" "$REPO_ROOT/scripts/lib/rail-plan.sh" "$check_fixture/scripts/lib/" +for helper in "$check_fixture/scripts/check/asm-ledger.sh" \ + "$check_fixture/scripts/check/check-feature-matrix.sh" \ + "$check_fixture/scripts/ct/python.sh" \ + "$check_fixture/scripts/test/test-feature-matrix.sh"; do + cat >"$helper" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$helper" +done + +run_check_consumer() { + local name=$1 + local plan=$2 + local status=$3 + local expected_scope=$4 + local output="$TMP_ROOT/check-$name.out" + : >"$command_log" + + if ! ( + unset RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + export HOME="$command_home" + export PATH="$command_bin:$jq_dir:/usr/bin:/bin" + export MOCK_LOG="$command_log" + export MOCK_PLAN_OUTPUT="$plan" + export MOCK_PLAN_STATUS="$status" + export RSCRYPTO_SKIP_CHECK_SUPPLY_CHAIN=1 + cd "$check_fixture" + bash scripts/check/check.sh + ) >"$output" 2>&1; then + cat "$output" >&2 + fail "check consumer failed for $name" + fi + + grep -Eq "^cargo check ${expected_scope}([[:space:]]|$)" "$command_log" \ + || fail "check consumer selected the wrong scope for $name" +} + +run_check_consumer planner-failure '' 9 '--workspace' +run_check_consumer valid-empty "$EMPTY_PLAN" 0 '--workspace' +run_check_consumer valid-workspace "$WORKSPACE_PLAN" 0 '--workspace' +run_check_consumer valid-crates "$CRATES_PLAN" 0 '-p crate-a -p crate-b' + +check_all_fixture="$TMP_ROOT/check-all-repository" +mkdir -p "$check_all_fixture/scripts/check" "$check_all_fixture/scripts/lib" +cp "$REPO_ROOT/scripts/check/check-all.sh" "$check_all_fixture/scripts/check/check-all.sh" +cp "$REPO_ROOT/scripts/lib/common.sh" "$REPO_ROOT/scripts/lib/rail-plan.sh" "$check_all_fixture/scripts/lib/" +cat >"$check_all_fixture/scripts/lib/targets.sh" <<'EOF' +WIN_TARGETS=() +LINUX_TARGETS=() +IBM_TARGETS=() +NOSTD_TARGETS=(mock-target) +WASM_TARGETS=() +EOF +for helper in check.sh zeroize-evidence.sh check-win.sh check-linux.sh check-ibm.sh; do + cat >"$check_all_fixture/scripts/check/$helper" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$check_all_fixture/scripts/check/$helper" +done +cat >"$command_bin/rustup" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "target list --installed" ]]; then + echo mock-target +fi +EOF +chmod +x "$command_bin/rustup" +printf '%s\n' '[package]' 'name = "fixture"' 'version = "0.0.0"' >"$check_all_fixture/Cargo.toml" + +run_check_all_consumer() { + local name=$1 + local plan=$2 + local status=$3 + local expected_crate=$4 + local output="$TMP_ROOT/check-all-$name.out" + : >"$command_log" + + if ! ( + unset RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + export HOME="$command_home" + export PATH="$command_bin:$jq_dir:/usr/bin:/bin" + export MOCK_LOG="$command_log" + export MOCK_PLAN_OUTPUT="$plan" + export MOCK_PLAN_STATUS="$status" + cd "$check_all_fixture" + bash scripts/check/check-all.sh + ) >"$output" 2>&1; then + cat "$output" >&2 + fail "check-all consumer failed for $name" + fi + + grep -Fq "cargo check -p $expected_crate --no-default-features --target mock-target --lib" "$command_log" \ + || fail "check-all selected the wrong constrained crate for $name" +} + +run_check_all_consumer planner-failure '' 9 rscrypto +run_check_all_consumer valid-empty "$EMPTY_PLAN" 0 rscrypto +run_check_all_consumer valid-workspace "$WORKSPACE_PLAN" 0 rscrypto +run_check_all_consumer valid-crates "$CRATES_PLAN" 0 crate-a + +run_workflow_resolver() { + local name=$1 + local outcome=$2 + local scope=$3 + local expected=$4 + local output="$TMP_ROOT/resolver-$name.outputs" + + : >"$output" + if ! GITHUB_OUTPUT="$output" \ + RAIL_PLAN_STEP_OUTCOME="$outcome" \ + RAIL_SCOPE_JSON="$scope" \ + bash "$REPO_ROOT/scripts/ci/resolve-rail-plan.sh"; then + fail "workflow resolver failed for $name" + fi + assert_eq "$expected" "$(<"$output")" "workflow outputs for $name" +} + +fallback_outputs=$'valid=false\nempty=false\nbuild=true\ntest=true\ninfra=true\ncargo_graph=true' +run_workflow_resolver planner-failure failure '' "$fallback_outputs" +run_workflow_resolver malformed-scope success '{' "$fallback_outputs" +run_workflow_resolver valid-empty success "$EMPTY_SCOPE" $'valid=true\nempty=true\nbuild=false\ntest=false\ninfra=false\ncargo_graph=false' +run_workflow_resolver valid-workspace success "$WORKSPACE_SCOPE" $'valid=true\nempty=false\nbuild=true\ntest=true\ninfra=false\ncargo_graph=false' +run_workflow_resolver valid-crates success "$CRATES_SCOPE" $'valid=true\nempty=false\nbuild=true\ntest=true\ninfra=false\ncargo_graph=false' + +echo "Changed-test planning regression tests passed" diff --git a/scripts/ci/check-ci-ownership-test.sh b/scripts/ci/check-ci-ownership-test.sh index 367ff909..642571bc 100755 --- a/scripts/ci/check-ci-ownership-test.sh +++ b/scripts/ci/check-ci-ownership-test.sh @@ -52,9 +52,27 @@ expect_failure "$missing_ready_event" "draft PR cannot start CI when marked read draft_runs_suite="$TMP_ROOT/draft-runs-suite" make_fixture "$draft_runs_suite" -yq eval '.jobs.suite.if = "always()"' -i "$draft_runs_suite/.github/workflows/ci.yaml" +yq eval '.jobs.suite."if" = "always()"' -i "$draft_runs_suite/.github/workflows/ci.yaml" expect_failure "$draft_runs_suite" "draft PR can run the expensive suite" +planner_failure_skips_suite="$TMP_ROOT/planner-failure-skips-suite" +make_fixture "$planner_failure_skips_suite" +yq eval '.jobs.suite."if" = "github.event_name != '\''pull_request'\'' || !github.event.pull_request.draft"' -i \ + "$planner_failure_skips_suite/.github/workflows/ci.yaml" +expect_failure "$planner_failure_skips_suite" "planner failure can skip the CI suite" + +missing_plan_resolver="$TMP_ROOT/missing-plan-resolver" +make_fixture "$missing_plan_resolver" +rm "$missing_plan_resolver/scripts/ci/resolve-rail-plan.sh" +expect_failure "$missing_plan_resolver" "workflow plan outputs bypass repository validation" + +unchecked_suite_skip="$TMP_ROOT/unchecked-suite-skip" +make_fixture "$unchecked_suite_skip" +sed -i.bak '/if \[\[ "\$PLAN_VALID" != "true" || "\$PLAN_EMPTY" != "true" \]\]/d' \ + "$unchecked_suite_skip/.github/workflows/ci.yaml" +rm -f "$unchecked_suite_skip/.github/workflows/ci.yaml.bak" +expect_failure "$unchecked_suite_skip" "Complete accepts an unvalidated suite skip" + shell_fragment_input="$TMP_ROOT/shell-fragment-input" make_fixture "$shell_fragment_input" printf '\n run_script: echo caller-controlled\n' \ diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index 1afe9eeb..44bb0055 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -31,6 +31,7 @@ EXECUTABLE_MATRIX="$ROOT/scripts/test/test-feature-matrix.sh" CHECK_ALL="$ROOT/scripts/check/check-all.sh" CI_CHECK="$ROOT/scripts/ci/ci-check.sh" RUN_RUST_JOB="$ROOT/scripts/ci/run-rust-job.sh" +RAIL_PLAN_RESOLVER="$ROOT/scripts/ci/resolve-rail-plan.sh" RELEASE_PREFLIGHT="$ROOT/scripts/ci/release-preflight.sh" RELEASE_EVIDENCE="$ROOT/scripts/ci/release-evidence-check.sh" RELEASE_SOURCE="$ROOT/scripts/ci/package-release-source.sh" @@ -100,6 +101,7 @@ require_file "$EXECUTABLE_MATRIX" require_file "$CHECK_ALL" require_file "$CI_CHECK" require_file "$RUN_RUST_JOB" +require_file "$RAIL_PLAN_RESOLVER" require_file "$RELEASE_PREFLIGHT" require_file "$RELEASE_EVIDENCE" require_file "$RELEASE_SOURCE" @@ -141,6 +143,22 @@ fi == $(yq eval '.jobs | length' "$CI") ]] \ || fail "every CI job must defer draft pull requests" +[[ $(yq eval '.jobs."rail-plan".steps[] | select(.id == "resolve") | .if' "$CI") == "always()" ]] \ + || fail "CI must resolve a conservative plan after planner failure" +suite_condition=$(yq eval '.jobs.suite."if"' "$CI") +[[ "$suite_condition" == *"always()"* && "$suite_condition" == *"needs.rail-plan.result != 'success'"* ]] \ + || fail "planner job failure must still run the CI suite" +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +grep -Fq 'plan_valid: ${{ steps.resolve.outputs.valid }}' "$CI" \ + || fail "CI must expose validated planner state" +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +grep -Fq 'plan_empty: ${{ steps.resolve.outputs.empty }}' "$CI" \ + || fail "CI must expose explicit empty planner state" +grep -Fq 'if [[ "$PLAN_VALID" != "true" || "$PLAN_EMPTY" != "true" ]]' "$CI" \ + || fail "Complete must reject an unvalidated suite skip" +[[ $(count_matches 'scripts/ci/resolve-rail-plan\.sh' "$CI") -eq 1 ]] \ + || fail "CI must use exactly one repository-owned plan resolver" + if grep -ERn '^[[:space:]]+(pre_script|run_script):' "$WORKFLOWS" >/dev/null; then fail "reusable workflows must not accept executable shell fragments" fi diff --git a/scripts/ci/check-worktree-test.sh b/scripts/ci/check-worktree-test.sh new file mode 100755 index 00000000..9c2529a0 --- /dev/null +++ b/scripts/ci/check-worktree-test.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +fail() { + echo "check worktree regression failure: $*" >&2 + exit 1 +} + +fixture="$TMP_ROOT/repository" +fake_bin="$TMP_ROOT/bin" +fake_home="$TMP_ROOT/home" +command_log="$TMP_ROOT/commands.log" +preflight_marker="$TMP_ROOT/locked-metadata-preflight" +mkdir -p \ + "$fixture/scripts/check" \ + "$fixture/scripts/ct" \ + "$fixture/scripts/lib" \ + "$fixture/scripts/test" \ + "$fixture/src" \ + "$fake_bin" \ + "$fake_home/.cargo/bin" + +cp \ + "$REPO_ROOT/scripts/check/asm-ledger.sh" \ + "$REPO_ROOT/scripts/check/check-all.sh" \ + "$REPO_ROOT/scripts/check/check-feature-matrix.sh" \ + "$REPO_ROOT/scripts/check/check-ibm.sh" \ + "$REPO_ROOT/scripts/check/check-linux.sh" \ + "$REPO_ROOT/scripts/check/check-win.sh" \ + "$REPO_ROOT/scripts/check/check.sh" \ + "$REPO_ROOT/scripts/check/zig-cc.sh" \ + "$fixture/scripts/check/" +cp "$REPO_ROOT/scripts/lib/common.sh" "$REPO_ROOT/scripts/lib/rail-plan.sh" "$fixture/scripts/lib/" +cp "$REPO_ROOT/scripts/test/test-feature-matrix.sh" "$fixture/scripts/test/test-feature-matrix.sh" + +cat >"$fixture/scripts/lib/targets.sh" <<'EOF' +WIN_TARGETS=(mock-win) +LINUX_TARGETS=(mock-linux) +IBM_TARGETS=(mock-ibm) +NOSTD_TARGETS=(mock-nostd) +WASM_TARGETS=() +EOF + +cat >"$fixture/scripts/ct/python.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + +cat >"$fixture/scripts/check/zeroize-evidence.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + +chmod +x "$fixture/scripts/ct/python.sh" "$fixture/scripts/check/zeroize-evidence.sh" + +cat >"$fake_bin/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf 'cargo %s\n' "$*" >>"$MOCK_LOG" + +mutate_source() { + printf '%s\n' '// rewritten by a mutating format check' >>"$MOCK_REPO_ROOT/src/lib.rs" +} + +mutate_lockfile() { + printf '%s\n' '# rewritten by an unlocked Cargo command' >>"$MOCK_REPO_ROOT/Cargo.lock" +} + +require_locked() { + if [[ " $* " != *" --locked "* ]]; then + mutate_lockfile + return 0 + fi + if [[ "${MOCK_LOCK_DRIFT:-0}" == "1" ]]; then + echo "error: the lock file needs to be updated but --locked was passed" >&2 + exit 41 + fi +} + +case "${1:-}" in + fmt) + if [[ " $* " != *" --check "* ]]; then + mutate_source + elif [[ "${MOCK_FORMAT_DRIFT:-0}" == "1" ]]; then + echo "Diff in src/lib.rs" >&2 + exit 40 + fi + ;; + metadata) + require_locked "$@" + : >"$MOCK_PREFLIGHT_MARKER" + ;; + rail) + if [[ "${2:-}" == "plan" ]]; then + if [[ ! -f "$MOCK_PREFLIGHT_MARKER" ]]; then + mutate_lockfile + fi + rm -f "$MOCK_PREFLIGHT_MARKER" + printf '%s\n' '{"schema_version":1,"command":"plan","mode":"inspect","result":"success","exit_code":0,"plan_contract_version":3,"files":[{"path":"src/lib.rs"}],"scope":{"scope_contract_version":2,"resolved_base":"base","resolved_head":"head","mode":"workspace","crates":[],"cargo_args":["--workspace"],"surfaces":{"bench":false,"build":true,"custom:cargo_graph":true,"docs":false,"infra":false,"test":true}}}' + fi + ;; + check | clippy | doc | test | build | rustc) + require_locked "$@" + ;; + xwin) + if [[ "${2:-}" != "--version" ]]; then + require_locked "$@" + fi + ;; + deny) + require_locked "$@" + ;; + audit | clean) + ;; +esac +EOF + +cat >"$fake_bin/rustup" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "target list --installed" ]]; then + printf '%s\n' mock-win mock-linux mock-ibm mock-nostd x86_64-pc-windows-msvc +fi +EOF + +cat >"$fake_bin/zig" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + +chmod +x "$fake_bin/cargo" "$fake_bin/rustup" "$fake_bin/zig" +ln -s /bin/bash "$fake_bin/bash" +cp "$fake_bin/cargo" "$fake_home/.cargo/bin/cargo" + +cat >"$fixture/Cargo.toml" <<'EOF' +[package] +name = "rscrypto" +version = "0.0.0" +edition = "2024" +EOF +cat >"$fixture/Cargo.lock" <<'EOF' +version = 4 + +[[package]] +name = "rscrypto" +version = "0.0.0" +EOF +cat >"$fixture/src/lib.rs" <<'EOF' +#![no_std] +EOF +cat >"$fixture/.gitignore" <<'EOF' +/target/ +EOF + +git -C "$fixture" init --quiet --initial-branch=main +git -C "$fixture" config user.email "ci@example.invalid" +git -C "$fixture" config user.name "CI" +git -C "$fixture" add . +git -C "$fixture" commit --quiet -m baseline + +printf '%s\n' 'pub const DIRTY_SOURCE: bool = true;' >>"$fixture/src/lib.rs" +printf '%s\n' '# pre-existing lockfile note' >>"$fixture/Cargo.lock" +printf '%s\n' 'pre-existing untracked file' >"$fixture/local-note.txt" + +snapshot_worktree() { + local output=$1 + { + git -C "$fixture" status --short --untracked-files=all + git -C "$fixture" diff --binary + git -C "$fixture" diff --cached --binary + git -C "$fixture" hash-object "$fixture/local-note.txt" + } >"$output" +} + +assert_worktree_unchanged() { + local before=$1 + local after=$2 + local description=$3 + if ! cmp -s "$before" "$after"; then + diff -u "$before" "$after" >&2 || true + fail "$description changed the pre-existing worktree" + fi +} + +run_recipe() { + local recipe=$1 + local expected_status=$2 + local format_drift=$3 + local lock_drift=$4 + local description=$5 + local before="$TMP_ROOT/$description.before" + local after="$TMP_ROOT/$description.after" + local output="$TMP_ROOT/$description.out" + local status=0 + + : >"$command_log" + rm -f "$preflight_marker" + snapshot_worktree "$before" + + ( + unset RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + export HOME="$fake_home" + export PATH="$fake_bin:$PATH" + export MOCK_FORMAT_DRIFT="$format_drift" + export MOCK_LOCK_DRIFT="$lock_drift" + export MOCK_LOG="$command_log" + export MOCK_PREFLIGHT_MARKER="$preflight_marker" + export MOCK_REPO_ROOT="$fixture" + just --justfile "$REPO_ROOT/justfile" --working-directory "$fixture" "$recipe" + ) >"$output" 2>&1 || status=$? + + if [[ "$expected_status" == success && "$status" -ne 0 ]]; then + cat "$output" >&2 + fail "$description unexpectedly failed" + fi + if [[ "$expected_status" == failure && "$status" -eq 0 ]]; then + cat "$output" >&2 + fail "$description unexpectedly succeeded" + fi + + if [[ "$format_drift" == 1 ]]; then + grep -Fq 'Diff in src/lib.rs' "$output" || fail "$description did not report formatting drift" + fi + if [[ "$lock_drift" == 1 ]]; then + grep -Fq 'lock file needs to be updated' "$output" || fail "$description did not report lockfile drift" + fi + + snapshot_worktree "$after" + assert_worktree_unchanged "$before" "$after" "$description" +} + +run_recipe check success 0 0 check-success +run_recipe check failure 1 0 check-format-failure +run_recipe check-all success 0 0 check-all-success +run_recipe check-all failure 0 1 check-all-lock-failure + +sed -n '/cargo rustc \\/,/--manifest-path/p' "$REPO_ROOT/scripts/check/zeroize-evidence.sh" \ + | grep -Fq -- '--locked' \ + || fail "zeroize evidence build is not locked" + +echo "Check worktree regression tests passed" diff --git a/scripts/ci/pre-push-test.sh b/scripts/ci/pre-push-test.sh index 2b62de85..46f8e3f3 100755 --- a/scripts/ci/pre-push-test.sh +++ b/scripts/ci/pre-push-test.sh @@ -19,8 +19,12 @@ cat >"$fake_bin/cargo" <<'SH' #!/usr/bin/env bash set -euo pipefail printf 'cargo %s\n' "$*" >>"$MOCK_LOG" +if [[ "${1:-}" == "rail" && "${2:-}" == "plan" ]]; then + printf '%s' "${MOCK_PLAN_OUTPUT:-}" + exit "${MOCK_PLAN_STATUS:-0}" +fi if [[ "$*" == "rail change check --merge-base --required" ]]; then - exit 42 + exit "${MOCK_RELEASE_CHECK_STATUS:-42}" fi SH chmod +x "$fake_bin/cargo" @@ -32,7 +36,10 @@ printf 'just %s\n' "$*" >>"$MOCK_LOG" SH chmod +x "$fake_bin/just" -plan='{"result":"success","files":[{"path":"Cargo.toml"}],"scope":{"mode":"workspace","surfaces":{"build":true,"test":true}}}' +workspace_scope='{"scope_contract_version":2,"resolved_base":"base","resolved_head":"head","mode":"workspace","crates":[],"cargo_args":["--workspace"],"surfaces":{"bench":false,"build":true,"custom:cargo_graph":true,"docs":false,"infra":false,"test":true}}' +empty_scope='{"scope_contract_version":2,"resolved_base":"base","resolved_head":"head","mode":"empty","crates":[],"cargo_args":[],"surfaces":{"bench":false,"build":false,"custom:cargo_graph":false,"docs":false,"infra":false,"test":false}}' +plan="$(jq -cn --argjson scope "$workspace_scope" '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[{path:"Cargo.toml"}],scope:$scope}')" +empty_plan="$(jq -cn --argjson scope "$empty_scope" '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[],scope:$scope}')" normal_output="$TMP_ROOT/normal.out" if ( @@ -50,6 +57,53 @@ if ( fi grep -Fq "cargo rail change check --merge-base --required" "$mock_log" +run_pre_push_case() { + local name=$1 + local cached_plan=$2 + local planner_output=$3 + local planner_status=$4 + local expect_host=$5 + local output="$TMP_ROOT/$name.out" + : >"$mock_log" + + if ! ( + unset RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON RAIL_SCOPE_JSON_CACHE + if [[ -n "$cached_plan" ]]; then + export RAIL_PLAN_JSON_CACHE="$cached_plan" + fi + cd "$fixture" + HOME="$TMP_ROOT/home" \ + PATH="$fake_bin:$PATH" \ + MOCK_LOG="$mock_log" \ + MOCK_PLAN_OUTPUT="$planner_output" \ + MOCK_PLAN_STATUS="$planner_status" \ + MOCK_RELEASE_CHECK_STATUS=0 \ + scripts/ci/pre-push.sh --light + ) >"$output" 2>&1; then + cat "$output" >&2 + echo "pre-push case failed: $name" >&2 + exit 1 + fi + + if [[ "$expect_host" == true ]]; then + grep -Fq "just check" "$mock_log" || { + echo "pre-push did not select host checks for $name" >&2 + exit 1 + } + grep -Fq "cargo rail unify --check --explain" "$mock_log" + grep -Fq "cargo rail change check --merge-base --required" "$mock_log" + elif grep -Fq "just check" "$mock_log"; then + echo "valid empty plan selected host checks" >&2 + exit 1 + fi +} + +run_pre_push_case planner-failure '' '' 9 true +run_pre_push_case empty-output '' '' 0 true +run_pre_push_case malformed-plan '{' '' 0 true +run_pre_push_case missing-scope "$(jq -c 'del(.scope)' <<<"$plan")" '' 0 true +run_pre_push_case valid-empty "$empty_plan" '' 0 false + plan_fixture="$TMP_ROOT/plan-repository" mkdir -p "$plan_fixture/.config" "$plan_fixture/scripts" "$plan_fixture/src" cp "$REPO_ROOT/.config/rail.toml" "$plan_fixture/.config/rail.toml" diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index 719376b5..65d55b2d 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -72,16 +72,15 @@ TASK_PIDS=() TASK_LOGS=() RAIL_READY=false -RAIL_PLAN_JSON_CACHE="$(rail_plan_json)" -export RAIL_PLAN_JSON_CACHE - -if [[ -n "$RAIL_PLAN_JSON_CACHE" ]] \ - && command -v jq >/dev/null 2>&1 \ - && jq -e '.result == "success"' <<<"$RAIL_PLAN_JSON_CACHE" >/dev/null 2>&1; then +if RAIL_PLAN_JSON_CACHE="$(rail_plan_json)" \ + && rail_plan_is_valid "$RAIL_PLAN_JSON_CACHE"; then RAIL_READY=true RAIL_SCOPE_JSON="$(rail_scope_json)" - export RAIL_SCOPE_JSON +else + RAIL_PLAN_JSON_CACHE="" + RAIL_SCOPE_JSON="" fi +export RAIL_PLAN_JSON_CACHE RAIL_SCOPE_JSON changed_files() { if [[ "$RAIL_READY" != true ]]; then diff --git a/scripts/ci/resolve-rail-plan.sh b/scripts/ci/resolve-rail-plan.sh new file mode 100755 index 00000000..9cf51eb1 --- /dev/null +++ b/scripts/ci/resolve-rail-plan.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/rail-plan.sh +source "$SCRIPT_DIR/../lib/rail-plan.sh" + +OUTPUT_PATH=${GITHUB_OUTPUT:?GITHUB_OUTPUT is required} + +write_outputs() { + local valid=$1 + local empty=$2 + local build=$3 + local test=$4 + local infra=$5 + local cargo_graph=$6 + + { + printf 'valid=%s\n' "$valid" + printf 'empty=%s\n' "$empty" + printf 'build=%s\n' "$build" + printf 'test=%s\n' "$test" + printf 'infra=%s\n' "$infra" + printf 'cargo_graph=%s\n' "$cargo_graph" + } >>"$OUTPUT_PATH" +} + +if [[ "${RAIL_PLAN_STEP_OUTCOME:-}" != "success" ]]; then + write_outputs false false true true true true + exit 0 +fi + +if ! scope_json="$(rail_scope_json)"; then + write_outputs false false true true true true + exit 0 +fi + +mode="$(jq -r '.mode' <<<"$scope_json")" +build="$(jq -r '.surfaces.build' <<<"$scope_json")" +infra="$(jq -r '.surfaces.infra' <<<"$scope_json")" +cargo_graph="$(jq -r '.surfaces["custom:cargo_graph"]' <<<"$scope_json")" + +if [[ "$mode" == "empty" ]]; then + write_outputs true true "$build" false "$infra" "$cargo_graph" +else + write_outputs true false "$build" true "$infra" "$cargo_graph" +fi diff --git a/scripts/lib/rail-plan.sh b/scripts/lib/rail-plan.sh index e5728509..1e71663e 100644 --- a/scripts/lib/rail-plan.sh +++ b/scripts/lib/rail-plan.sh @@ -18,73 +18,170 @@ rail_plan_json() { local since_arg since_arg="$(rail_since_args)" + if ! cargo metadata --locked --format-version 1 >/dev/null 2>&1; then + RAIL_PLAN_JSON_CACHE="" + return 1 + fi + local plan_output # shellcheck disable=SC2086 - plan_output="$(cargo rail plan $since_arg --quiet --json 2>/dev/null || true)" + if ! plan_output="$(cargo rail plan $since_arg --quiet --json 2>/dev/null)"; then + RAIL_PLAN_JSON_CACHE="" + return 1 + fi + if [[ -z "$plan_output" ]]; then + RAIL_PLAN_JSON_CACHE="" + return 1 + fi + RAIL_PLAN_JSON_CACHE="$plan_output" printf '%s\n' "$RAIL_PLAN_JSON_CACHE" } +_rail_valid_scope_json() { + local document_kind=$1 + + jq -ce --arg document_kind "$document_kind" ' + def valid_surfaces: + type == "object" + and has("bench") + and has("build") + and has("custom:cargo_graph") + and has("docs") + and has("infra") + and has("test") + and all(.[]; type == "boolean"); + + def valid_crate: + type == "string" and test("^[A-Za-z0-9][A-Za-z0-9_-]*$"); + + def expected_cargo_args: + if .mode == "empty" then + [] + elif .mode == "workspace" then + ["--workspace"] + else + [.crates[] | "-p", .] + end; + + def valid_scope: + type == "object" + and .scope_contract_version == 2 + and (.resolved_base | type == "string" and length > 0) + and (.resolved_head | type == "string" and length > 0) + and (.mode == "empty" or .mode == "workspace" or .mode == "crates") + and (.crates | type == "array") + and (.cargo_args | type == "array" and all(.[]; type == "string")) + and (.surfaces | valid_surfaces) + and ( + if .mode == "crates" then + (.crates + | length > 0 + and all(.[]; valid_crate) + and ((unique | length) == length)) + else + (.crates | length == 0) + end + ) + and (.cargo_args == expected_cargo_args) + and ( + if .mode == "empty" then + (.surfaces.bench == false and .surfaces.build == false and .surfaces.test == false) + else + true + end + ); + + if $document_kind == "plan" then + select( + type == "object" + and .schema_version == 1 + and .command == "plan" + and .mode == "inspect" + and .result == "success" + and .exit_code == 0 + and .plan_contract_version == 3 + and (.files | type == "array") + and all(.files[]; + type == "object" + and (.path + | type == "string" + and length > 0 + and (explode | all(. >= 32 and . != 127)))) + ) + | .scope + | select(valid_scope) + elif $document_kind == "scope" then + select(valid_scope) + else + empty + end + ' +} + +rail_plan_is_valid() { + local plan_output=$1 + [[ -n "$plan_output" ]] || return 1 + printf '%s\n' "$plan_output" | _rail_valid_scope_json plan >/dev/null 2>&1 +} + rail_scope_json() { - if [[ -n "${RAIL_SCOPE_JSON:-}" ]]; then - printf '%s\n' "$RAIL_SCOPE_JSON" - return 0 - fi + local scope_output - if [[ -n "${RAIL_SCOPE_JSON_CACHE:-}" ]]; then - printf '%s\n' "$RAIL_SCOPE_JSON_CACHE" - return 0 + if [[ -n "${RAIL_SCOPE_JSON:-}" ]]; then + scope_output="$RAIL_SCOPE_JSON" + elif [[ -n "${RAIL_SCOPE_JSON_CACHE:-}" ]]; then + scope_output="$RAIL_SCOPE_JSON_CACHE" + else + local plan_output + if ! plan_output="$(rail_plan_json)"; then + return 1 + fi + if ! scope_output="$(printf '%s\n' "$plan_output" | _rail_valid_scope_json plan 2>/dev/null)"; then + return 1 + fi fi - local plan_output - plan_output="$(rail_plan_json)" - if [[ -z "$plan_output" ]]; then - echo "" - return 0 + if ! RAIL_SCOPE_JSON_CACHE="$(printf '%s\n' "$scope_output" | _rail_valid_scope_json scope 2>/dev/null)"; then + RAIL_SCOPE_JSON_CACHE="" + return 1 fi - - RAIL_SCOPE_JSON_CACHE="$(echo "$plan_output" | jq -c '.scope // empty' 2>/dev/null || true)" printf '%s\n' "$RAIL_SCOPE_JSON_CACHE" } rail_scope_mode() { local scope_output - scope_output="$(rail_scope_json)" - - if [[ -z "$scope_output" ]]; then - echo "" + if ! scope_output="$(rail_scope_json)"; then + echo "workspace" return 0 fi - echo "$scope_output" | jq -r '.mode // empty' 2>/dev/null || echo "" + printf '%s\n' "$scope_output" | jq -r '.mode' } rail_scope_surface_enabled() { local surface=$1 local scope_output - scope_output="$(rail_scope_json)" - - if [[ -z "$scope_output" ]]; then - return 1 + if ! scope_output="$(rail_scope_json)"; then + return 0 fi - echo "$scope_output" | jq -e --arg surface "$surface" '.surfaces[$surface] == true' >/dev/null 2>&1 + printf '%s\n' "$scope_output" \ + | jq -e --arg surface "$surface" '.surfaces[$surface] == true' >/dev/null 2>&1 } rail_plan_crates() { local scope_output - scope_output="$(rail_scope_json)" - - if [[ -z "$scope_output" ]]; then + if ! scope_output="$(rail_scope_json)"; then echo "" return 0 fi - echo "$scope_output" | jq -r ' + printf '%s\n' "$scope_output" | jq -r ' if .mode == "crates" then - (.crates // [])[] + .crates[] else empty end - ' 2>/dev/null | tr -d '\r' | tr '\n' ' ' | xargs || echo "" + ' } diff --git a/scripts/test/test-feature-matrix.sh b/scripts/test/test-feature-matrix.sh index 2185b49c..ff855e83 100755 --- a/scripts/test/test-feature-matrix.sh +++ b/scripts/test/test-feature-matrix.sh @@ -77,11 +77,11 @@ STARTED_AT=$SECONDS # provided by the --all-features test job. ARCH=$(uname -m) if [[ "$ARCH" == "riscv64" ]]; then - CARGO_CMD="cargo check --workspace --lib --tests" + CARGO_CMD="cargo check --locked --workspace --lib --tests" COMMAND_CLASS="cargo check" echo "Compilation rscrypto feature matrix ($TOTAL profiles; riscv64: check-only)" else - CARGO_CMD="cargo test --workspace --lib --tests" + CARGO_CMD="cargo test --locked --workspace --lib --tests" COMMAND_CLASS="cargo test" echo "Executable rscrypto feature matrix ($TOTAL profiles)" fi diff --git a/scripts/test/test.sh b/scripts/test/test.sh index 33b624c7..1f52f7b6 100755 --- a/scripts/test/test.sh +++ b/scripts/test/test.sh @@ -90,8 +90,8 @@ run_rail_scoped_doctests() { local affected affected="$(rail_plan_crates)" if [ -z "$affected" ]; then - echo "no doc-test targets" - return 0 + run_workspace_doctests + return fi local crates=() @@ -160,25 +160,14 @@ else echo "Running tests for planner-selected scope" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - if ! rail_scope_surface_enabled test; then - echo "no test targets" - else - case "$(rail_scope_mode)" in - workspace) - if [ "$HAS_NEXTEST" = true ]; then - cargo nextest run --workspace -P "$PROFILE" --all-features --config-file .config/nextest.toml "${NEXTEST_THREAD_ARGS[@]:+${NEXTEST_THREAD_ARGS[@]}}" - run_workspace_doctests - else - cargo test --workspace --all-features "${CARGO_TEST_TARGET_ARGS[@]:+${CARGO_TEST_TARGET_ARGS[@]}}" - fi - ;; - crates) - affected="$(rail_plan_crates)" - if [ -z "$affected" ]; then - echo "no test targets" - exit 0 - fi - + case "$(rail_scope_mode)" in + empty) + echo "no test targets" + exit 0 + ;; + crates) + affected="$(rail_plan_crates)" + if [ -n "$affected" ]; then if [ "$HAS_NEXTEST" = true ]; then CRATE_FLAGS=() for crate in $affected; do @@ -191,10 +180,15 @@ else cargo test -p "$crate" --all-features "${CARGO_TEST_TARGET_ARGS[@]:+${CARGO_TEST_TARGET_ARGS[@]}}" done fi - ;; - *) - echo "no test targets" - ;; - esac + exit 0 + fi + ;; + esac + + if [ "$HAS_NEXTEST" = true ]; then + cargo nextest run --workspace -P "$PROFILE" --all-features --config-file .config/nextest.toml "${NEXTEST_THREAD_ARGS[@]:+${NEXTEST_THREAD_ARGS[@]}}" + run_workspace_doctests + else + cargo test --workspace --all-features "${CARGO_TEST_TARGET_ARGS[@]:+${CARGO_TEST_TARGET_ARGS[@]}}" fi fi